Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb0a14aa7 | |||
| 95f32f914e | |||
| 84184fafb5 | |||
| 1d4b749d55 | |||
| 836cc385b3 | |||
| 814cf763f8 | |||
| 3ede3ec2a8 | |||
| d98ff7d19c | |||
| 635b53eeaf | |||
| 2e4b5ac659 | |||
| 385f044144 | |||
| 7b0f3a2c03 | |||
| 3a7c4ca7a9 | |||
| 4091d1d4b5 | |||
| f40f79a2db | |||
| 603b9a24bf | |||
| ecb6158024 | |||
| ae13ce14c2 | |||
| e3b219288e | |||
| 6204d5fc2b | |||
| f735f0b49b | |||
| a4870fa0c3 | |||
| f1b8cd3a7a |
+31
-4
@@ -127,6 +127,13 @@ fi
|
||||
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "Python changes detected, running backend checks..."
|
||||
|
||||
# Detect if we're in a worktree
|
||||
IS_WORKTREE=false
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
# Determine ruff command (venv or global)
|
||||
RUFF=""
|
||||
if [ -f "apps/backend/.venv/bin/ruff" ]; then
|
||||
@@ -158,7 +165,16 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
fi
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: ruff not available in this worktree."
|
||||
echo " Python linting checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
@@ -192,17 +208,28 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
elif [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Python venv not available in this worktree."
|
||||
echo " Python tests will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
exit 77 # GNU convention for 'test skipped' (avoids pytest exit-code collision)
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
PYTHON_EXIT=$?
|
||||
if [ $PYTHON_EXIT -eq 77 ]; then
|
||||
echo "Backend checks passed! (Python tests skipped — worktree)"
|
||||
elif [ $PYTHON_EXIT -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
else
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+12
-20
@@ -97,9 +97,8 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^apps/backend/
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Python tests (apps/backend/) - run full test suite from project root
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -108,31 +107,24 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
# Run pytest directly from project root
|
||||
if [ -f "apps/backend/.venv/bin/pytest" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/bin/pytest"
|
||||
elif [ -f "apps/backend/.venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
$PYTEST_CMD tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=../../tests/test_graphiti.py \
|
||||
--ignore=../../tests/test_merge_file_tracker.py \
|
||||
--ignore=../../tests/test_service_orchestrator.py \
|
||||
--ignore=../../tests/test_worktree.py \
|
||||
--ignore=../../tests/test_workspace.py
|
||||
--ignore=tests/test_graphiti.py \
|
||||
--ignore=tests/test_merge_file_tracker.py \
|
||||
--ignore=tests/test_service_orchestrator.py \
|
||||
--ignore=tests/test_worktree.py \
|
||||
--ignore=tests/test_workspace.py
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
@@ -52,6 +52,15 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**Electron path resolution** — For bug fixes in the Electron app, always check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
|
||||
|
||||
### Resetting PR Review State
|
||||
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
|
||||
|
||||
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
|
||||
2. `rm .auto-claude/github/pr/review_*.json` — review result files
|
||||
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
|
||||
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
[](https://github.com/hesreallyhim/awesome-claude-code)
|
||||
|
||||
---
|
||||
|
||||
@@ -35,18 +36,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.4)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.5)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -67,4 +67,9 @@ tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
coverage.json
|
||||
|
||||
# Auto Claude generated files
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.security-key
|
||||
logs/security/
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.4"
|
||||
__version__ = "2.7.6-beta.5"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectAnalyzer:
|
||||
"""Run full project analysis."""
|
||||
self._detect_project_type()
|
||||
self._find_and_analyze_services()
|
||||
self._aggregate_dependency_locations()
|
||||
self._analyze_infrastructure()
|
||||
self._detect_conventions()
|
||||
self._map_dependencies()
|
||||
@@ -124,6 +125,63 @@ class ProjectAnalyzer:
|
||||
|
||||
self.index["services"] = services
|
||||
|
||||
def _aggregate_dependency_locations(self) -> None:
|
||||
"""Aggregate dependency location metadata from all services.
|
||||
|
||||
Collects dependency_locations from each service and stores them as
|
||||
paths relative to the project root (e.g., 'apps/backend/.venv'
|
||||
instead of just '.venv').
|
||||
"""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
|
||||
for service_name, service_info in self.index.get("services", {}).items():
|
||||
service_deps = service_info.get("dependency_locations", [])
|
||||
service_path = service_info.get("path", "")
|
||||
|
||||
# Compute service-relative prefix once per service
|
||||
service_rel: Path | None = None
|
||||
if service_path:
|
||||
try:
|
||||
service_rel = Path(service_path).relative_to(self.project_dir)
|
||||
except ValueError:
|
||||
# Service path is outside the project root — skip its deps
|
||||
# to avoid producing absolute paths that bypass containment
|
||||
continue
|
||||
|
||||
for dep in service_deps:
|
||||
dep_path = dep.get("path")
|
||||
if not dep_path:
|
||||
continue
|
||||
|
||||
# Build project-relative path from service path + dep path
|
||||
if service_rel is not None:
|
||||
project_relative = str(service_rel / dep_path)
|
||||
else:
|
||||
project_relative = dep_path
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": dep.get("type", "unknown"),
|
||||
"path": project_relative,
|
||||
"exists": dep.get("exists", False),
|
||||
"service": service_name,
|
||||
}
|
||||
if dep.get("requirements_file"):
|
||||
# Convert to project-relative path like we do for "path"
|
||||
if service_rel is not None:
|
||||
entry["requirements_file"] = str(
|
||||
service_rel / dep["requirements_file"]
|
||||
)
|
||||
else:
|
||||
entry["requirements_file"] = dep["requirements_file"]
|
||||
pkg_mgr = dep.get("package_manager") or service_info.get(
|
||||
"package_manager"
|
||||
)
|
||||
if pkg_mgr:
|
||||
entry["package_manager"] = pkg_mgr
|
||||
aggregated.append(entry)
|
||||
|
||||
self.index["dependency_locations"] = aggregated
|
||||
|
||||
def _analyze_infrastructure(self) -> None:
|
||||
"""Analyze infrastructure configuration."""
|
||||
infra = {}
|
||||
|
||||
@@ -40,6 +40,8 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
self._find_key_directories()
|
||||
self._find_entry_points()
|
||||
self._detect_dependencies()
|
||||
self._detect_dependency_locations()
|
||||
self._detect_package_manager()
|
||||
self._detect_testing()
|
||||
self._find_dockerfile()
|
||||
|
||||
@@ -209,6 +211,121 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
deps.append(match.group(1))
|
||||
self.analysis["dependencies"] = deps[:20]
|
||||
|
||||
def _detect_dependency_locations(self) -> None:
|
||||
"""Detect where dependencies live on disk for this service."""
|
||||
locations: list[dict[str, Any]] = []
|
||||
|
||||
# Node.js: node_modules (only if package.json exists)
|
||||
if self._exists("package.json"):
|
||||
node_modules = self.path / "node_modules"
|
||||
locations.append(
|
||||
{
|
||||
"type": "node_modules",
|
||||
"path": "node_modules",
|
||||
"exists": node_modules.exists() and node_modules.is_dir(),
|
||||
}
|
||||
)
|
||||
|
||||
# Python: .venv or venv
|
||||
for venv_dir in [".venv", "venv"]:
|
||||
venv_path = self.path / venv_dir
|
||||
if venv_path.exists() and venv_path.is_dir():
|
||||
entry: dict[str, Any] = {
|
||||
"type": "venv",
|
||||
"path": venv_dir,
|
||||
"exists": True,
|
||||
}
|
||||
# Find requirements file
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
entry["requirements_file"] = req_file
|
||||
break
|
||||
locations.append(entry)
|
||||
break
|
||||
else:
|
||||
# No venv found, still record requirements file if present
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
locations.append(
|
||||
{
|
||||
"type": "venv",
|
||||
"path": ".venv",
|
||||
"exists": False,
|
||||
"requirements_file": req_file,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# PHP: vendor
|
||||
vendor_path = self.path / "vendor"
|
||||
if vendor_path.exists() and vendor_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_php",
|
||||
"path": "vendor",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Rust: target
|
||||
target_path = self.path / "target"
|
||||
if target_path.exists() and target_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "cargo_target",
|
||||
"path": "target",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Ruby: vendor/bundle
|
||||
bundle_path = self.path / "vendor" / "bundle"
|
||||
if bundle_path.exists() and bundle_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_bundle",
|
||||
"path": "vendor/bundle",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.analysis["dependency_locations"] = locations
|
||||
|
||||
def _detect_package_manager(self) -> None:
|
||||
"""Detect the package manager used by this service."""
|
||||
# Node.js package managers
|
||||
if self._exists("package-lock.json"):
|
||||
self.analysis["package_manager"] = "npm"
|
||||
elif self._exists("yarn.lock"):
|
||||
self.analysis["package_manager"] = "yarn"
|
||||
elif self._exists("pnpm-lock.yaml"):
|
||||
self.analysis["package_manager"] = "pnpm"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
self.analysis["package_manager"] = "bun"
|
||||
# Python package managers
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
elif self._exists("pyproject.toml"):
|
||||
if self._exists("uv.lock"):
|
||||
self.analysis["package_manager"] = "uv"
|
||||
elif self._exists("poetry.lock"):
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
elif self._exists("requirements.txt"):
|
||||
self.analysis["package_manager"] = "pip"
|
||||
# Other
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["package_manager"] = "go_mod"
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["package_manager"] = "gem"
|
||||
elif self._exists("composer.json"):
|
||||
self.analysis["package_manager"] = "composer"
|
||||
else:
|
||||
self.analysis["package_manager"] = None
|
||||
|
||||
def _detect_testing(self) -> None:
|
||||
"""Detect testing framework and configuration."""
|
||||
if self._exists("package.json"):
|
||||
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from qa.criteria import is_fixes_applied, is_qa_approved, is_qa_rejected
|
||||
from ui import highlight, print_status
|
||||
|
||||
|
||||
@@ -151,13 +152,22 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Determine status
|
||||
if (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
status = "building"
|
||||
elif (spec_dir / "qa_report.md").exists():
|
||||
# Determine status (highest priority first)
|
||||
# Use authoritative QA status check, not just file existence
|
||||
if is_qa_approved(spec_dir):
|
||||
status = "qa_approved"
|
||||
elif is_qa_rejected(spec_dir):
|
||||
status = "qa_rejected"
|
||||
elif is_fixes_applied(spec_dir):
|
||||
status = "fixes_applied"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
# Check if there's a qa_report.md but no approval yet (QA in progress)
|
||||
if (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_in_progress"
|
||||
else:
|
||||
status = "building"
|
||||
elif (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
else:
|
||||
status = "pending_spec"
|
||||
|
||||
@@ -165,7 +175,10 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
"pending_spec": "⏳",
|
||||
"spec_created": "📋",
|
||||
"building": "⚙️",
|
||||
"qa_in_progress": "🔍",
|
||||
"qa_approved": "✅",
|
||||
"qa_rejected": "❌",
|
||||
"fixes_applied": "🔧",
|
||||
"unknown": "❓",
|
||||
}.get(status, "❓")
|
||||
|
||||
@@ -192,10 +205,10 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print_status("No specs directory found", "info")
|
||||
return True
|
||||
|
||||
# Find completed specs
|
||||
# Find completed specs (only QA-approved, matching status display logic)
|
||||
completed = []
|
||||
for spec_dir in specs_dir.iterdir():
|
||||
if spec_dir.is_dir() and (spec_dir / "qa_report.md").exists():
|
||||
if spec_dir.is_dir() and is_qa_approved(spec_dir):
|
||||
completed.append(spec_dir.name)
|
||||
|
||||
if not completed:
|
||||
|
||||
@@ -449,7 +449,7 @@ def _handle_build_interrupt(
|
||||
if choice == "skip":
|
||||
print()
|
||||
print_status("Resuming build...", "info")
|
||||
status_manager.update(state=BuildState.RUNNING)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir,
|
||||
|
||||
@@ -694,10 +694,25 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
if _debug:
|
||||
# Log which auth env vars are set (presence only, never values)
|
||||
set_vars = [v for v in AUTH_TOKEN_ENV_VARS if os.environ.get(v)]
|
||||
logger.info(
|
||||
"[Auth] get_auth_token() called — config_dir param=%s, "
|
||||
"env vars present: %s, CLAUDE_CONFIG_DIR env=%s",
|
||||
repr(config_dir),
|
||||
set_vars or "(none)",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
# First check environment variables (highest priority)
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from env var: %s", var)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
@@ -705,12 +720,13 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# Debug: Log which config_dir is being used for credential resolution
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
if debug and effective_config_dir:
|
||||
if _debug and effective_config_dir:
|
||||
service_name = _get_keychain_service_name(effective_config_dir)
|
||||
logger.info(
|
||||
f"[Auth] Resolving credentials for profile config_dir: {effective_config_dir} "
|
||||
f"(Keychain service: {service_name})"
|
||||
"[Auth] Resolving credentials for profile config_dir: %s "
|
||||
"(Keychain service: %s)",
|
||||
effective_config_dir,
|
||||
service_name,
|
||||
)
|
||||
|
||||
# If a custom config directory is specified, read from there first
|
||||
@@ -718,24 +734,37 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
# Try reading from .credentials.json file in the config directory
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from config dir file: %s",
|
||||
effective_config_dir,
|
||||
)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Also try the system credential store with hash-based service name
|
||||
# This is needed because macOS stores credentials in Keychain, not files
|
||||
token = get_token_from_keychain(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from Keychain (profile-specific)")
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# If config_dir was explicitly provided, DON'T fall back to default keychain
|
||||
# - that would return the wrong profile's token
|
||||
logger.debug(
|
||||
f"No credentials found for config_dir '{effective_config_dir}' "
|
||||
"in file or keychain"
|
||||
"No credentials found for config_dir '%s' in file or keychain",
|
||||
effective_config_dir,
|
||||
)
|
||||
return None
|
||||
|
||||
# No config_dir specified - use default system credential store
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
keychain_token = get_token_from_keychain()
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from default Keychain: %s",
|
||||
"found" if keychain_token else "not found",
|
||||
)
|
||||
return _try_decrypt_token(keychain_token)
|
||||
|
||||
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
@@ -970,8 +999,18 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] configure_sdk_authentication() — mode=%s, config_dir=%s, "
|
||||
"CLAUDE_CONFIG_DIR env=%s",
|
||||
"api_profile" if api_profile_mode else "oauth",
|
||||
repr(config_dir),
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
if api_profile_mode:
|
||||
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
|
||||
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
@@ -999,6 +1038,14 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
logger.info("Using OAuth authentication")
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] SDK env check — CLAUDE_CONFIG_DIR=%s, "
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=%s",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
"set" if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") else "unset",
|
||||
)
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,11 @@ Enhanced with colored output, icons, and better visual formatting.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.plan_normalization import normalize_subtask_aliases
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -230,8 +233,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
|
||||
)
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Ignore corrupted/unreadable progress files
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.debug(f"Failed to load plan file for phase summary: {e}")
|
||||
else:
|
||||
print()
|
||||
print_status("No implementation subtasks yet - planner needs to run", "pending")
|
||||
@@ -404,6 +407,8 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
"""
|
||||
Find the next subtask to work on, respecting phase dependencies.
|
||||
|
||||
Skips subtasks that are marked as stuck in the recovery manager's attempt history.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing implementation_plan.json
|
||||
|
||||
@@ -415,6 +420,23 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
|
||||
# Load stuck subtasks from recovery manager's attempt history
|
||||
stuck_subtask_ids = set()
|
||||
attempt_history_file = spec_dir / "memory" / "attempt_history.json"
|
||||
if attempt_history_file.exists():
|
||||
try:
|
||||
with open(attempt_history_file, encoding="utf-8") as f:
|
||||
attempt_history = json.load(f)
|
||||
# Collect IDs of subtasks marked as stuck
|
||||
stuck_subtask_ids = {
|
||||
entry["subtask_id"]
|
||||
for entry in attempt_history.get("stuck_subtasks", [])
|
||||
if "subtask_id" in entry
|
||||
}
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# If we can't read the file, continue without stuck checking
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
@@ -455,9 +477,15 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
if not deps_satisfied:
|
||||
continue
|
||||
|
||||
# Find first pending subtask in this phase
|
||||
# Find first pending subtask in this phase (skip stuck subtasks)
|
||||
for subtask in phase.get("subtasks", phase.get("chunks", [])):
|
||||
status = subtask.get("status", "pending")
|
||||
subtask_id = subtask.get("id")
|
||||
|
||||
# Skip stuck subtasks
|
||||
if subtask_id in stuck_subtask_ids:
|
||||
continue
|
||||
|
||||
if status in {"pending", "not_started", "not started"}:
|
||||
subtask_out, _changed = normalize_subtask_aliases(subtask)
|
||||
subtask_out["status"] = "pending"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Dependency Strategy Mapping
|
||||
============================
|
||||
|
||||
Maps dependency types to sharing strategies for worktree creation.
|
||||
|
||||
Each dependency ecosystem has different constraints:
|
||||
|
||||
- **node_modules**: Safe to symlink. Node's resolution algorithm follows symlinks
|
||||
correctly, and the directory is self-contained.
|
||||
|
||||
- **venv / .venv**: Symlinked for fast worktree creation. CPython bug #106045
|
||||
(pyvenv.cfg symlink resolution) does not affect typical usage (running scripts,
|
||||
imports, pip). A health check after symlinking verifies usability; if it fails,
|
||||
the caller falls back to recreating the venv.
|
||||
|
||||
- **vendor (PHP)**: Safe to symlink. Composer's autoloader uses ``__DIR__``-relative
|
||||
paths that resolve correctly through symlinks.
|
||||
|
||||
- **cargo target / go modules**: Skip entirely. Rust's ``target/`` dir contains
|
||||
per-machine build artifacts that must be rebuilt. Go uses a global module cache
|
||||
(``$GOPATH/pkg/mod``), so there is nothing in-tree to share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import DependencyShareConfig, DependencyStrategy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default strategy map
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps dependency type identifiers to the strategy that should be used when
|
||||
# sharing that dependency across worktrees. Data-driven — add new entries
|
||||
# here rather than writing if/else branches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_STRATEGY_MAP: dict[str, DependencyStrategy] = {
|
||||
# JavaScript / Node.js — symlink is safe and fast
|
||||
"node_modules": DependencyStrategy.SYMLINK,
|
||||
# Python — symlink for fast worktree creation (health check + fallback to recreate)
|
||||
"venv": DependencyStrategy.SYMLINK,
|
||||
".venv": DependencyStrategy.SYMLINK,
|
||||
# PHP — Composer vendor dir is safe to symlink
|
||||
"vendor_php": DependencyStrategy.SYMLINK,
|
||||
# Ruby — Bundler vendor/bundle is safe to symlink
|
||||
"vendor_bundle": DependencyStrategy.SYMLINK,
|
||||
# Rust — build output dir, skip (rebuilt per-worktree)
|
||||
"cargo_target": DependencyStrategy.SKIP,
|
||||
# Go — global module cache, nothing in-tree to share
|
||||
"go_modules": DependencyStrategy.SKIP,
|
||||
}
|
||||
|
||||
|
||||
def get_dependency_configs(
|
||||
project_index: dict | None,
|
||||
project_dir: Path | None = None,
|
||||
) -> list[DependencyShareConfig]:
|
||||
"""Derive dependency share configs from a project index.
|
||||
|
||||
If *project_index* is ``None`` or lacks ``dependency_locations``,
|
||||
falls back to a hardcoded node_modules config for backward compatibility
|
||||
with existing worktree setups.
|
||||
|
||||
Args:
|
||||
project_index: Parsed ``project_index.json`` dict, or ``None``.
|
||||
project_dir: Project root directory for resolved-path containment
|
||||
checks (defense-in-depth). Should always be provided when
|
||||
*project_index* is not ``None`` — omitting it disables the
|
||||
resolved-path security check.
|
||||
|
||||
Returns:
|
||||
List of :class:`DependencyShareConfig` objects — one per discovered
|
||||
dependency location.
|
||||
"""
|
||||
|
||||
configs: list[DependencyShareConfig] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if project_index is not None:
|
||||
if project_dir is None:
|
||||
logger.warning(
|
||||
"get_dependency_configs called with project_index but no "
|
||||
"project_dir — resolved-path containment check is disabled"
|
||||
)
|
||||
|
||||
# Use the aggregated top-level dependency_locations which already
|
||||
# contain project-relative paths (e.g. "apps/backend/.venv" instead
|
||||
# of just ".venv"). This avoids a monorepo path resolution bug
|
||||
# where service-relative paths were incorrectly treated as project-
|
||||
# relative.
|
||||
dep_locations = project_index.get("dependency_locations") or []
|
||||
for dep in dep_locations:
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
|
||||
dep_type = dep.get("type", "")
|
||||
rel_path = dep.get("path", "")
|
||||
|
||||
if not dep_type or not rel_path:
|
||||
continue
|
||||
|
||||
# Path containment: reject absolute paths and traversals.
|
||||
# Check both POSIX and Windows path styles for cross-platform safety.
|
||||
p = PurePosixPath(rel_path)
|
||||
if p.is_absolute() or PureWindowsPath(rel_path).is_absolute():
|
||||
continue
|
||||
if ".." in p.parts or ".." in PureWindowsPath(rel_path).parts:
|
||||
continue
|
||||
|
||||
# Defense-in-depth: verify the resolved path stays within project_dir
|
||||
if project_dir is not None:
|
||||
resolved = (project_dir / rel_path).resolve()
|
||||
if not str(resolved).startswith(str(project_dir.resolve()) + os.sep):
|
||||
continue
|
||||
|
||||
# Deduplicate by relative path
|
||||
if rel_path in seen:
|
||||
continue
|
||||
seen.add(rel_path)
|
||||
|
||||
strategy = DEFAULT_STRATEGY_MAP.get(dep_type, DependencyStrategy.SKIP)
|
||||
|
||||
# Validate requirements_file path containment too
|
||||
req_file = dep.get("requirements_file")
|
||||
if req_file:
|
||||
rp = PurePosixPath(req_file)
|
||||
if (
|
||||
rp.is_absolute()
|
||||
or PureWindowsPath(req_file).is_absolute()
|
||||
or ".." in rp.parts
|
||||
or ".." in PureWindowsPath(req_file).parts
|
||||
):
|
||||
req_file = None
|
||||
|
||||
# Defense-in-depth: resolved-path containment (matches rel_path check)
|
||||
if req_file and project_dir is not None:
|
||||
resolved_req = (project_dir / req_file).resolve()
|
||||
if not str(resolved_req).startswith(
|
||||
str(project_dir.resolve()) + os.sep
|
||||
):
|
||||
req_file = None
|
||||
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type=dep_type,
|
||||
strategy=strategy,
|
||||
source_rel_path=rel_path,
|
||||
requirements_file=req_file,
|
||||
package_manager=dep.get("package_manager"),
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback: if no configs were discovered, default to node_modules-only
|
||||
# so existing worktree behaviour is preserved.
|
||||
if not configs:
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="node_modules",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="apps/frontend/node_modules",
|
||||
)
|
||||
)
|
||||
|
||||
return configs
|
||||
@@ -273,3 +273,30 @@ class SpecNumberLock:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
|
||||
class DependencyStrategy(Enum):
|
||||
"""Strategy for sharing dependency directories across worktrees.
|
||||
|
||||
SYMLINK is fast and now safe for Python venvs with runtime health checks.
|
||||
A post-symlink health check validates the venv is usable, automatically
|
||||
falling back to RECREATE if the symlink is broken. This works around
|
||||
CPython's pyvenv.cfg discovery issue (CPython bug #106045) while maintaining
|
||||
fast worktree creation in the common case where symlinking succeeds.
|
||||
"""
|
||||
|
||||
SYMLINK = "symlink" # Create a symlink to the source (fast, works for node_modules)
|
||||
RECREATE = "recreate" # Re-run the package manager to create a fresh copy
|
||||
COPY = "copy" # Deep-copy the directory (slow but always correct)
|
||||
SKIP = "skip" # Do nothing; let the agent handle it
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyShareConfig:
|
||||
"""Configuration for how a specific dependency type should be shared."""
|
||||
|
||||
dep_type: str # e.g. "node_modules", "venv", ".venv"
|
||||
strategy: DependencyStrategy
|
||||
source_rel_path: str # Relative path from project root, e.g. "node_modules"
|
||||
requirements_file: str | None = None # e.g. "requirements.txt", "pyproject.toml"
|
||||
package_manager: str | None = None # e.g. "npm", "uv", "pip"
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from core.platform import is_windows
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
@@ -28,8 +29,9 @@ from ui import (
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
from .dependency_strategy import get_dependency_configs
|
||||
from .git_utils import has_uncommitted_changes
|
||||
from .models import WorkspaceMode
|
||||
from .models import DependencyShareConfig, DependencyStrategy, WorkspaceMode
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
@@ -48,6 +50,10 @@ _git_hook_check_done = False
|
||||
|
||||
MODULE = "workspace.setup"
|
||||
|
||||
# Marker file written inside a recreated venv to indicate setup completed successfully.
|
||||
# If the marker is absent, the venv is treated as incomplete and will be rebuilt.
|
||||
VENV_SETUP_COMPLETE_MARKER = ".setup_complete"
|
||||
|
||||
|
||||
def choose_workspace(
|
||||
project_dir: Path,
|
||||
@@ -189,11 +195,37 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
Symlink node_modules directories from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to dependencies for TypeScript checks
|
||||
and other tooling without requiring a separate npm install.
|
||||
.. deprecated::
|
||||
Use :func:`setup_worktree_dependencies` instead, which handles all
|
||||
dependency types (node_modules, venvs, vendor dirs, etc.) via
|
||||
strategy-based dispatch.
|
||||
|
||||
Works with npm workspace hoisting where dependencies are hoisted to root
|
||||
and workspace-specific dependencies remain in nested node_modules.
|
||||
This is a thin backward-compatibility wrapper that delegates to
|
||||
``setup_worktree_dependencies()`` with no project index (fallback mode).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of symlinked paths (relative to worktree)
|
||||
"""
|
||||
results = setup_worktree_dependencies(
|
||||
project_dir, worktree_path, project_index=None
|
||||
)
|
||||
# Flatten all processed paths for backward-compatible return value
|
||||
return [path for paths in results.values() for path in paths]
|
||||
|
||||
|
||||
def symlink_claude_config_to_worktree(
|
||||
project_dir: Path, worktree_path: Path
|
||||
) -> list[str]:
|
||||
"""
|
||||
Symlink .claude/ directory from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to Claude Code configuration
|
||||
(settings, CLAUDE.md, MCP servers, etc.) so that terminals opened
|
||||
in the worktree behave identically to the project root.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
@@ -204,81 +236,52 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
symlinked = []
|
||||
|
||||
# Node modules locations to symlink for TypeScript and tooling support.
|
||||
# These are the standard locations for this monorepo structure.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Hardcoded paths are intentional for simplicity and reliability
|
||||
# - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
# and potential failure points without significant benefit
|
||||
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
#
|
||||
# To add new workspace locations:
|
||||
# 1. Add (source_rel, target_rel) tuple below
|
||||
# 2. Update the parallel TypeScript implementation in
|
||||
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
|
||||
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
node_modules_locations = [
|
||||
("node_modules", "node_modules"),
|
||||
("apps/frontend/node_modules", "apps/frontend/node_modules"),
|
||||
]
|
||||
source_path = project_dir / ".claude"
|
||||
target_path = worktree_path / ".claude"
|
||||
|
||||
for source_rel, target_rel in node_modules_locations:
|
||||
source_path = project_dir / source_rel
|
||||
target_path = worktree_path / target_rel
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - source does not exist")
|
||||
return symlinked
|
||||
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping {source_rel} - source does not exist")
|
||||
continue
|
||||
# Skip if target already exists
|
||||
if target_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - target already exists")
|
||||
return symlinked
|
||||
|
||||
# Skip if target already exists (don't overwrite existing node_modules)
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping {target_rel} - target already exists")
|
||||
continue
|
||||
# Also skip if target is a symlink (even if broken)
|
||||
if target_path.is_symlink():
|
||||
debug(MODULE, "Skipping .claude/ - symlink already exists (possibly broken)")
|
||||
return symlinked
|
||||
|
||||
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
|
||||
if target_path.is_symlink():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping {target_rel} - symlink already exists (possibly broken)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
# Junctions require absolute paths
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(target_rel)
|
||||
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
|
||||
except OSError as e:
|
||||
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
|
||||
# Log warning but don't fail - worktree is still usable, just without
|
||||
# TypeScript checking
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
|
||||
)
|
||||
# Warn user - pre-commit hooks may fail without dependencies
|
||||
print_status(
|
||||
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
|
||||
"warning",
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(".claude")
|
||||
debug(MODULE, f"Symlinked .claude/ -> {source_path}")
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink .claude/: {e}. Claude Code features may not work in worktree terminals.",
|
||||
)
|
||||
print_status(
|
||||
"Warning: Could not link .claude/ - Claude Code features may not work in terminals",
|
||||
"warning",
|
||||
)
|
||||
|
||||
return symlinked
|
||||
|
||||
@@ -374,13 +377,33 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Symlink node_modules to worktree for TypeScript and tooling support
|
||||
# This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
symlinked_modules = symlink_node_modules_to_worktree(
|
||||
# Set up dependencies in worktree using strategy-based dispatch
|
||||
# Load project index if available for ecosystem-aware dependency handling
|
||||
project_index = None
|
||||
project_index_path = project_dir / ".auto-claude" / "project_index.json"
|
||||
if project_index_path.is_file():
|
||||
try:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
project_index = json.load(f)
|
||||
debug(MODULE, "Loaded project_index.json for dependency setup")
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
debug_warning(MODULE, f"Could not load project_index.json: {e}")
|
||||
|
||||
dep_results = setup_worktree_dependencies(
|
||||
project_dir, worktree_info.path, project_index=project_index
|
||||
)
|
||||
for strategy_name, paths in dep_results.items():
|
||||
if paths:
|
||||
print_status(
|
||||
f"Dependencies ({strategy_name}): {', '.join(paths)}", "success"
|
||||
)
|
||||
|
||||
# Symlink .claude/ config to worktree for Claude Code features (settings, commands, etc.)
|
||||
symlinked_claude = symlink_claude_config_to_worktree(
|
||||
project_dir, worktree_info.path
|
||||
)
|
||||
if symlinked_modules:
|
||||
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
|
||||
if symlinked_claude:
|
||||
print_status(f"Claude config linked: {', '.join(symlinked_claude)}", "success")
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
@@ -574,6 +597,409 @@ def initialize_timeline_tracking(
|
||||
print(muted(f" Note: Timeline tracking could not be initialized: {e}"))
|
||||
|
||||
|
||||
def setup_worktree_dependencies(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
project_index: dict | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Set up dependencies in a worktree using strategy-based dispatch.
|
||||
|
||||
Reads dependency configs from the project index and applies the correct
|
||||
strategy for each: symlink, recreate, copy, or skip.
|
||||
|
||||
All operations are non-blocking — failures produce warnings but do not
|
||||
prevent worktree creation.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
project_index: Parsed project_index.json dict, or None
|
||||
|
||||
Returns:
|
||||
Dict mapping strategy names to lists of paths that were processed.
|
||||
"""
|
||||
configs = get_dependency_configs(project_index, project_dir=project_dir)
|
||||
results: dict[str, list[str]] = {}
|
||||
|
||||
for config in configs:
|
||||
strategy_name = config.strategy.value
|
||||
if strategy_name not in results:
|
||||
results[strategy_name] = []
|
||||
|
||||
try:
|
||||
performed = False
|
||||
if config.strategy == DependencyStrategy.SYMLINK:
|
||||
performed = _apply_symlink_strategy(project_dir, worktree_path, config)
|
||||
# For venvs, verify the symlink is usable — fall back to recreate
|
||||
# Run health check whenever a venv symlink exists (not just on creation)
|
||||
if config.dep_type in ("venv", ".venv"):
|
||||
venv_path = worktree_path / config.source_rel_path
|
||||
# Check if venv exists (symlinked or otherwise)
|
||||
if venv_path.exists() or venv_path.is_symlink():
|
||||
if is_windows():
|
||||
python_bin = str(venv_path / "Scripts" / "python.exe")
|
||||
else:
|
||||
python_bin = str(venv_path / "bin" / "python")
|
||||
try:
|
||||
subprocess.run(
|
||||
[python_bin, "-c", "import sys; print(sys.prefix)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=True,
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Symlinked venv health check passed: {config.source_rel_path}",
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Symlinked venv health check failed, falling back to recreate: {config.source_rel_path}",
|
||||
)
|
||||
# Remove the broken symlink and recreate
|
||||
try:
|
||||
if venv_path.is_symlink():
|
||||
venv_path.unlink()
|
||||
elif venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
except OSError:
|
||||
pass # Best-effort removal; recreate strategy handles existing paths
|
||||
performed = _apply_recreate_strategy(
|
||||
project_dir, worktree_path, config
|
||||
)
|
||||
# Update strategy name to reflect fallback
|
||||
if performed:
|
||||
strategy_name = "recreate"
|
||||
# Ensure the key exists for the fallback strategy
|
||||
results.setdefault(strategy_name, [])
|
||||
elif config.strategy == DependencyStrategy.RECREATE:
|
||||
performed = _apply_recreate_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.COPY:
|
||||
performed = _apply_copy_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.SKIP:
|
||||
_apply_skip_strategy(config)
|
||||
# Don't record skipped entries — only report actual work
|
||||
continue
|
||||
if performed:
|
||||
results[strategy_name].append(config.source_rel_path)
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Failed to apply {strategy_name} strategy for "
|
||||
f"{config.source_rel_path}: {e}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _apply_symlink_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a symlink (or Windows junction) from worktree to project source.
|
||||
|
||||
Returns True if a symlink was created, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists() or target_path.is_symlink():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
# Windows: use directory junctions (no admin rights required).
|
||||
# os.symlink creates a directory symlink that needs admin/DevMode,
|
||||
# so we use mklink /J which creates a junction without privileges.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# macOS/Linux: relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
debug(MODULE, f"Symlinked {config.source_rel_path} -> {source_path}")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Symlink creation timed out for {config.source_rel_path}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Symlink creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {config.source_rel_path}: {e}",
|
||||
)
|
||||
print_status(f"Warning: Could not link {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _popen_with_cleanup(
|
||||
cmd: list[str],
|
||||
timeout: int,
|
||||
label: str,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run a command via Popen with proper process cleanup on timeout.
|
||||
|
||||
On timeout: terminate → wait(10) → kill → wait(5) to ensure file locks
|
||||
are released before any cleanup (e.g. shutil.rmtree).
|
||||
|
||||
Returns (returncode, stdout, stderr).
|
||||
Raises subprocess.TimeoutExpired if the command exceeds the given timeout (after cleanup is attempted).
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
return proc.returncode, stdout, stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(MODULE, f"{label} timed out, terminating process")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(MODULE, f"{label} did not terminate, killing process")
|
||||
proc.kill()
|
||||
try:
|
||||
proc.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Final cleanup attempt if kill() also hangs
|
||||
debug_warning(MODULE, f"{label} could not be stopped even after kill()")
|
||||
raise
|
||||
finally:
|
||||
# Ensure pipes are closed and process is reaped to avoid zombie processes
|
||||
if proc.stdout:
|
||||
proc.stdout.close()
|
||||
if proc.stderr:
|
||||
proc.stderr.close()
|
||||
try:
|
||||
proc.wait(timeout=0.1)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Process still running, already logged warning above
|
||||
|
||||
|
||||
def _apply_recreate_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a fresh virtual environment in the worktree and install deps.
|
||||
|
||||
Returns True if the venv was successfully created, False if skipped or failed.
|
||||
"""
|
||||
venv_path = worktree_path / config.source_rel_path
|
||||
marker_path = venv_path / VENV_SETUP_COMPLETE_MARKER
|
||||
|
||||
# Check for broken symlinks that exists() would miss
|
||||
if venv_path.is_symlink() and not venv_path.exists():
|
||||
debug(MODULE, f"Removing broken symlink at {config.source_rel_path}")
|
||||
try:
|
||||
venv_path.unlink()
|
||||
except OSError:
|
||||
pass # Best-effort removal
|
||||
elif venv_path.exists():
|
||||
if marker_path.exists():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping recreate {config.source_rel_path} - already complete (marker present)",
|
||||
)
|
||||
return False
|
||||
# Venv exists but marker is missing — incomplete, remove and rebuild
|
||||
debug(MODULE, f"Removing incomplete venv {config.source_rel_path} (no marker)")
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
|
||||
# Detect Python executable from the source venv or fall back to sys.executable
|
||||
source_venv = project_dir / config.source_rel_path
|
||||
python_exec = sys.executable
|
||||
|
||||
if source_venv.exists():
|
||||
# Try to use the same Python version as the source venv
|
||||
for candidate in ("bin/python", "Scripts/python.exe"):
|
||||
candidate_path = source_venv / candidate
|
||||
if candidate_path.exists():
|
||||
python_exec = str(candidate_path.resolve())
|
||||
break
|
||||
|
||||
# Create the venv
|
||||
try:
|
||||
debug(MODULE, f"Creating venv at {venv_path}")
|
||||
returncode, _, stderr = _popen_with_cleanup(
|
||||
[python_exec, "-m", "venv", str(venv_path)],
|
||||
timeout=120,
|
||||
label=f"venv creation ({config.source_rel_path})",
|
||||
)
|
||||
if returncode != 0:
|
||||
debug_warning(MODULE, f"venv creation failed: {stderr}")
|
||||
print_status(
|
||||
f"Warning: Could not create venv at {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print_status(
|
||||
f"Warning: venv creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(MODULE, f"venv creation failed: {e}")
|
||||
print_status(
|
||||
f"Warning: Could not create venv at {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
# Install from requirements file if specified
|
||||
req_file = config.requirements_file
|
||||
if req_file:
|
||||
req_path = project_dir / req_file
|
||||
if req_path.is_file():
|
||||
# Determine pip executable inside the new venv
|
||||
if is_windows():
|
||||
pip_exec = str(venv_path / "Scripts" / "pip.exe")
|
||||
else:
|
||||
pip_exec = str(venv_path / "bin" / "pip")
|
||||
|
||||
# Build install command based on file type
|
||||
req_basename = Path(req_file).name
|
||||
if req_basename == "pyproject.toml":
|
||||
# pyproject.toml: snapshot-install from the worktree copy.
|
||||
# Non-editable so the venv doesn't symlink back to the source.
|
||||
worktree_req = worktree_path / req_file
|
||||
install_dir = str(
|
||||
worktree_req.parent if worktree_req.is_file() else req_path.parent
|
||||
)
|
||||
install_cmd = [pip_exec, "install", install_dir]
|
||||
elif req_basename == "Pipfile":
|
||||
# Pipfile: not directly installable via pip, skip
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping Pipfile-based install for {req_file} "
|
||||
"(use pipenv in the worktree)",
|
||||
)
|
||||
install_cmd = None
|
||||
else:
|
||||
# requirements.txt or similar: pip install -r
|
||||
install_cmd = [pip_exec, "install", "-r", str(req_path)]
|
||||
|
||||
if install_cmd:
|
||||
try:
|
||||
debug(MODULE, f"Installing deps from {req_file}")
|
||||
returncode, _, stderr = _popen_with_cleanup(
|
||||
install_cmd,
|
||||
timeout=300,
|
||||
label=f"pip install ({req_file})",
|
||||
)
|
||||
if returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install failed (exit {returncode}): {stderr}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install failed for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print_status(
|
||||
f"Warning: Dependency install timed out for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(MODULE, f"pip install failed: {e}")
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
# Write completion marker so future runs know this venv is complete
|
||||
try:
|
||||
marker_path.touch()
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE, f"Failed to write completion marker at {marker_path}: {e}"
|
||||
)
|
||||
|
||||
debug(MODULE, f"Recreated venv at {config.source_rel_path}")
|
||||
return True
|
||||
|
||||
|
||||
def _apply_copy_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Deep-copy a dependency directory from project to worktree.
|
||||
|
||||
Returns True if the copy was performed, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if source_path.is_file():
|
||||
shutil.copy2(source_path, target_path)
|
||||
else:
|
||||
shutil.copytree(source_path, target_path)
|
||||
debug(MODULE, f"Copied {config.source_rel_path} to worktree")
|
||||
return True
|
||||
except (OSError, shutil.Error) as e:
|
||||
debug_warning(MODULE, f"Could not copy {config.source_rel_path}: {e}")
|
||||
print_status(f"Warning: Could not copy {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_skip_strategy(config: DependencyShareConfig) -> None:
|
||||
"""Skip — nothing to do for this dependency type."""
|
||||
debug(
|
||||
MODULE, f"Skipping {config.dep_type} ({config.source_rel_path}) - skip strategy"
|
||||
)
|
||||
|
||||
|
||||
# Export private functions for backward compatibility
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking
|
||||
|
||||
@@ -430,6 +430,7 @@ class WorktreeManager:
|
||||
if os.path.samefile(resolved_path, current_path):
|
||||
return line[len("branch refs/heads/") :]
|
||||
except OSError:
|
||||
# File system comparison errors are handled by fallback below
|
||||
pass
|
||||
# Fallback to normalized case comparison
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
@@ -510,6 +511,7 @@ class WorktreeManager:
|
||||
if os.path.samefile(resolved_path, registered_path):
|
||||
return True
|
||||
except OSError:
|
||||
# File system errors handled by fallback comparison below
|
||||
pass
|
||||
# Fallback to normalized case comparison for non-existent paths
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
|
||||
@@ -5,7 +5,9 @@ Handles database connection, initialization, and lifecycle management.
|
||||
Uses LadybugDB as the embedded graph database (no Docker required, Python 3.12+).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -14,6 +16,27 @@ from graphiti_config import GraphitiConfig, GraphitiState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Retry configuration for LadybugDB lock contention
|
||||
MAX_LOCK_RETRIES = 5
|
||||
INITIAL_BACKOFF_SECONDS = 0.5
|
||||
MAX_BACKOFF_SECONDS = 8.0
|
||||
JITTER_PERCENT = 0.2
|
||||
|
||||
|
||||
def _is_lock_error(error: Exception) -> bool:
|
||||
"""Check if an error indicates database lock contention."""
|
||||
error_msg = str(error).lower()
|
||||
return "could not set lock" in error_msg or (
|
||||
"lock" in error_msg and ("file" in error_msg or "database" in error_msg)
|
||||
)
|
||||
|
||||
|
||||
def _backoff_with_jitter(attempt: int) -> float:
|
||||
"""Calculate exponential backoff with jitter for retry delays."""
|
||||
backoff = min(INITIAL_BACKOFF_SECONDS * (2**attempt), MAX_BACKOFF_SECONDS)
|
||||
jitter = backoff * JITTER_PERCENT * (2 * random.random() - 1)
|
||||
return max(0.01, backoff + jitter)
|
||||
|
||||
|
||||
def _apply_ladybug_monkeypatch() -> bool:
|
||||
"""
|
||||
@@ -196,32 +219,36 @@ class GraphitiClient:
|
||||
)
|
||||
|
||||
db_path = self.config.get_db_path()
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error initializing LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
|
||||
# Retry with exponential backoff for lock contention
|
||||
for attempt in range(MAX_LOCK_RETRIES + 1):
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"LadybugDB lock acquired after {attempt} retries"
|
||||
)
|
||||
break # Success
|
||||
except Exception as e:
|
||||
if _is_lock_error(e) and attempt < MAX_LOCK_RETRIES:
|
||||
wait_time = _backoff_with_jitter(attempt)
|
||||
logger.debug(
|
||||
f"LadybugDB lock contention (attempt {attempt + 1}/{MAX_LOCK_RETRIES}), retrying in {wait_time:.2f}s"
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"Initialized LadybugDB driver (patched) at: {db_path}")
|
||||
except ImportError as e:
|
||||
logger.warning(f"KuzuDriver not available: {e}")
|
||||
|
||||
@@ -215,6 +215,7 @@ async def run_qa_validation_loop(
|
||||
"Removed QA_FIX_REQUEST.md after permanent fixer error",
|
||||
)
|
||||
except OSError:
|
||||
# File removal failure is not critical here
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -230,6 +231,7 @@ async def run_qa_validation_loop(
|
||||
fix_request_file.unlink()
|
||||
debug("qa_loop", "Removed processed QA_FIX_REQUEST.md")
|
||||
except OSError:
|
||||
# File removal failure is not critical here
|
||||
pass # Ignore if file removal fails
|
||||
|
||||
# Check for no-test projects
|
||||
|
||||
@@ -572,6 +572,9 @@ class PRReviewResult:
|
||||
) # IDs of posted findings
|
||||
posted_at: str | None = None # Timestamp when findings were posted
|
||||
|
||||
# In-progress review tracking
|
||||
in_progress_since: str | None = None # ISO timestamp when active review started
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pr_number": self.pr_number,
|
||||
@@ -603,6 +606,8 @@ class PRReviewResult:
|
||||
"has_posted_findings": self.has_posted_findings,
|
||||
"posted_finding_ids": self.posted_finding_ids,
|
||||
"posted_at": self.posted_at,
|
||||
# In-progress review tracking
|
||||
"in_progress_since": self.in_progress_since,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -650,6 +655,8 @@ class PRReviewResult:
|
||||
has_posted_findings=data.get("has_posted_findings", False),
|
||||
posted_finding_ids=data.get("posted_finding_ids", []),
|
||||
posted_at=data.get("posted_at"),
|
||||
# In-progress review tracking
|
||||
in_progress_since=data.get("in_progress_since"),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
|
||||
@@ -395,8 +395,28 @@ class GitHubOrchestrator:
|
||||
else:
|
||||
# No existing review found, create skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
elif "Review already in progress" in skip_reason:
|
||||
# Return an in-progress result WITHOUT saving to disk
|
||||
# to avoid overwriting the partial result being written by the active review
|
||||
started_at = self.bot_detector.state.in_progress_reviews.get(
|
||||
str(pr_number)
|
||||
)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Review in progress for PR #{pr_number} "
|
||||
f"(started: {started_at})",
|
||||
flush=True,
|
||||
)
|
||||
return PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=[],
|
||||
summary="Review in progress",
|
||||
overall_status="in_progress",
|
||||
in_progress_since=started_at,
|
||||
)
|
||||
else:
|
||||
# For other skip reasons (bot-authored, cooling off, in-progress), create a skip result
|
||||
# For other skip reasons (bot-authored, cooling off), create a skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
|
||||
@@ -235,6 +235,12 @@ async def cmd_review_pr(args) -> int:
|
||||
safe_print(f"[DEBUG] review_pr returned, success={result.success}")
|
||||
|
||||
if result.success:
|
||||
# For in_progress results (not saved to disk), output JSON so the frontend
|
||||
# can parse it from stdout instead of relying on the disk file.
|
||||
if result.overall_status == "in_progress":
|
||||
safe_print(f"__RESULT_JSON__:{json.dumps(result.to_dict())}")
|
||||
return 0
|
||||
|
||||
safe_print(f"\n{'=' * 60}")
|
||||
safe_print(f"PR #{result.pr_number} Review Complete")
|
||||
safe_print(f"{'=' * 60}")
|
||||
|
||||
@@ -25,6 +25,8 @@ if TYPE_CHECKING:
|
||||
from ..models import FollowupReviewContext, GitHubRunnerConfig
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import resolve_model_id
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
MergeVerdict,
|
||||
@@ -37,8 +39,11 @@ try:
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .prompt_manager import PromptManager
|
||||
from .pydantic_models import FollowupReviewResponse
|
||||
from .pydantic_models import FollowupExtractionResponse, FollowupReviewResponse
|
||||
from .recovery_utils import create_finding_from_summary
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
MergeVerdict,
|
||||
@@ -48,10 +53,16 @@ except (ImportError, ValueError, SystemError):
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from phase_config import resolve_model_id
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.pydantic_models import FollowupReviewResponse
|
||||
from services.pydantic_models import (
|
||||
FollowupExtractionResponse,
|
||||
FollowupReviewResponse,
|
||||
)
|
||||
from services.recovery_utils import create_finding_from_summary
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -698,6 +709,9 @@ Analyze this follow-up review context and provide your structured response.
|
||||
)
|
||||
safe_print(f"[Followup] SDK query with output_format, model={model}")
|
||||
|
||||
# Capture assistant text for extraction fallback
|
||||
captured_text = ""
|
||||
|
||||
# Iterate through messages from the query
|
||||
# Note: max_turns=2 because structured output uses a tool call + response
|
||||
async for message in query(
|
||||
@@ -722,7 +736,9 @@ Analyze this follow-up review context and provide your structured response.
|
||||
content = getattr(message, "content", [])
|
||||
for block in content:
|
||||
block_type = type(block).__name__
|
||||
if block_type == "ToolUseBlock":
|
||||
if block_type == "TextBlock":
|
||||
captured_text += getattr(block, "text", "")
|
||||
elif block_type == "ToolUseBlock":
|
||||
tool_name = getattr(block, "name", "")
|
||||
if tool_name == "StructuredOutput":
|
||||
# Extract structured data from tool input
|
||||
@@ -765,9 +781,31 @@ Analyze this follow-up review context and provide your structured response.
|
||||
logger.warning(
|
||||
"Claude could not produce valid structured output after retries"
|
||||
)
|
||||
# Attempt extraction call recovery before giving up
|
||||
if captured_text:
|
||||
safe_print(
|
||||
"[Followup] Attempting extraction call recovery...",
|
||||
flush=True,
|
||||
)
|
||||
extraction_result = await self._attempt_extraction_call(
|
||||
captured_text, context
|
||||
)
|
||||
if extraction_result is not None:
|
||||
return extraction_result
|
||||
return None
|
||||
|
||||
logger.warning("No structured output received from AI")
|
||||
# Attempt extraction call recovery before giving up
|
||||
if captured_text:
|
||||
safe_print(
|
||||
"[Followup] No structured output — attempting extraction call recovery...",
|
||||
flush=True,
|
||||
)
|
||||
extraction_result = await self._attempt_extraction_call(
|
||||
captured_text, context
|
||||
)
|
||||
if extraction_result is not None:
|
||||
return extraction_result
|
||||
return None
|
||||
|
||||
except ValueError as e:
|
||||
@@ -840,6 +878,124 @@ Analyze this follow-up review context and provide your structured response.
|
||||
"verdict_reasoning": result.verdict_reasoning,
|
||||
}
|
||||
|
||||
async def _attempt_extraction_call(
|
||||
self,
|
||||
text: str,
|
||||
context: FollowupReviewContext,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Attempt a short SDK call with minimal schema to recover review data.
|
||||
|
||||
This is the extraction recovery step when full structured output validation fails.
|
||||
Uses FollowupExtractionResponse (small schema with ExtractedFindingSummary nesting)
|
||||
which has near-100% success rate.
|
||||
|
||||
Uses create_client() + process_sdk_stream() for proper OAuth handling,
|
||||
matching the pattern in parallel_followup_reviewer.py.
|
||||
|
||||
Returns parsed result dict on success, None on failure.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
extraction_prompt = (
|
||||
"Extract the key review data from the following AI analysis output. "
|
||||
"Return the verdict, reasoning, resolved finding IDs, unresolved finding IDs, "
|
||||
"structured summaries of any new findings (including severity, description, file path, and line number), "
|
||||
"and counts of confirmed/dismissed findings.\n\n"
|
||||
f"--- AI ANALYSIS OUTPUT ---\n{text[:8000]}\n--- END ---"
|
||||
)
|
||||
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
|
||||
extraction_client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_followup_extraction",
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FollowupExtractionResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
async with extraction_client:
|
||||
await extraction_client.query(extraction_prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=extraction_client,
|
||||
context_name="FollowupExtraction",
|
||||
model=model,
|
||||
system_prompt=extraction_prompt,
|
||||
max_messages=20,
|
||||
)
|
||||
|
||||
if stream_result.get("error"):
|
||||
logger.warning(
|
||||
f"[Followup] Extraction call also failed: {stream_result['error']}"
|
||||
)
|
||||
return None
|
||||
|
||||
extraction_output = stream_result.get("structured_output")
|
||||
if not extraction_output:
|
||||
logger.warning(
|
||||
"[Followup] Extraction call returned no structured output"
|
||||
)
|
||||
return None
|
||||
|
||||
extracted = FollowupExtractionResponse.model_validate(extraction_output)
|
||||
|
||||
# Convert extraction to internal format with reconstructed findings
|
||||
new_findings = []
|
||||
for i, summary_obj in enumerate(extracted.new_finding_summaries):
|
||||
new_findings.append(
|
||||
create_finding_from_summary(
|
||||
summary=summary_obj.description,
|
||||
index=i,
|
||||
id_prefix="FR",
|
||||
severity_override=summary_obj.severity,
|
||||
file=summary_obj.file,
|
||||
line=summary_obj.line,
|
||||
)
|
||||
)
|
||||
|
||||
# Build finding_resolutions from extraction data for _apply_ai_resolutions
|
||||
# (unresolved findings are handled via finding_resolutions + _apply_ai_resolutions)
|
||||
finding_resolutions = []
|
||||
for fid in extracted.resolved_finding_ids:
|
||||
finding_resolutions.append(
|
||||
{"finding_id": fid, "status": "resolved", "resolution_notes": None}
|
||||
)
|
||||
for fid in extracted.unresolved_finding_ids:
|
||||
finding_resolutions.append(
|
||||
{
|
||||
"finding_id": fid,
|
||||
"status": "unresolved",
|
||||
"resolution_notes": None,
|
||||
}
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[Followup] Extraction recovered: verdict={extracted.verdict}, "
|
||||
f"{len(extracted.resolved_finding_ids)} resolved, "
|
||||
f"{len(extracted.unresolved_finding_ids)} unresolved, "
|
||||
f"{len(new_findings)} new findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"finding_resolutions": finding_resolutions,
|
||||
"new_findings": new_findings,
|
||||
"comment_findings": [],
|
||||
"verdict": extracted.verdict,
|
||||
"verdict_reasoning": f"[Recovered via extraction] {extracted.verdict_reasoning}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Followup] Extraction call failed: {e}")
|
||||
return None
|
||||
|
||||
def _apply_ai_resolutions(
|
||||
self,
|
||||
previous_findings: list[PRReviewFinding],
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
from .io_utils import safe_print
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import FollowupExtractionResponse, ParallelFollowupResponse
|
||||
from .recovery_utils import create_finding_from_summary
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import _validate_git_ref
|
||||
@@ -79,6 +80,7 @@ except (ImportError, ValueError, SystemError):
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from services.recovery_utils import create_finding_from_summary
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
@@ -1127,7 +1129,8 @@ The SDK will run invoked agents in parallel automatically.
|
||||
"""Attempt a short SDK call with a minimal schema to recover review data.
|
||||
|
||||
This is the Tier 2 recovery step when full structured output validation fails.
|
||||
Uses FollowupExtractionResponse (~6 flat fields) which has near-100% success rate.
|
||||
Uses FollowupExtractionResponse (small schema with ExtractedFindingSummary nesting)
|
||||
which has near-100% success rate.
|
||||
|
||||
Returns parsed result dict on success, None on failure.
|
||||
"""
|
||||
@@ -1144,7 +1147,8 @@ The SDK will run invoked agents in parallel automatically.
|
||||
extraction_prompt = (
|
||||
"Extract the key review data from the following AI analysis output. "
|
||||
"Return the verdict, reasoning, resolved finding IDs, unresolved finding IDs, "
|
||||
"one-line summaries of any new findings, and counts of confirmed/dismissed findings.\n\n"
|
||||
"structured summaries of any new findings (including severity, description, file path, and line number), "
|
||||
"and counts of confirmed/dismissed findings.\n\n"
|
||||
f"--- AI ANALYSIS OUTPUT ---\n{text[:8000]}\n--- END ---"
|
||||
)
|
||||
|
||||
@@ -1199,18 +1203,59 @@ The SDK will run invoked agents in parallel automatically.
|
||||
}
|
||||
verdict = verdict_map.get(extracted.verdict, MergeVerdict.NEEDS_REVISION)
|
||||
|
||||
# Reconstruct findings from extraction data
|
||||
findings = []
|
||||
new_finding_ids = []
|
||||
|
||||
# 1. Convert new_finding_summaries to PRReviewFinding objects
|
||||
# ExtractedFindingSummary objects carry file/line from extraction
|
||||
for i, summary_obj in enumerate(extracted.new_finding_summaries):
|
||||
finding = create_finding_from_summary(
|
||||
summary=summary_obj.description,
|
||||
index=i,
|
||||
id_prefix="FU",
|
||||
severity_override=summary_obj.severity,
|
||||
file=summary_obj.file,
|
||||
line=summary_obj.line,
|
||||
)
|
||||
new_finding_ids.append(finding.id)
|
||||
findings.append(finding)
|
||||
|
||||
# 2. Reconstruct unresolved findings from previous review context
|
||||
if extracted.unresolved_finding_ids and context.previous_review.findings:
|
||||
previous_map = {f.id: f for f in context.previous_review.findings}
|
||||
for uid in extracted.unresolved_finding_ids:
|
||||
original = previous_map.get(uid)
|
||||
if original:
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=original.id,
|
||||
severity=original.severity,
|
||||
category=original.category,
|
||||
title=f"[UNRESOLVED] {original.title}",
|
||||
description=original.description,
|
||||
file=original.file,
|
||||
line=original.line,
|
||||
suggested_fix=original.suggested_fix,
|
||||
fixable=original.fixable,
|
||||
is_impact_finding=original.is_impact_finding,
|
||||
)
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction recovered: verdict={extracted.verdict}, "
|
||||
f"{len(extracted.resolved_finding_ids)} resolved, "
|
||||
f"{len(extracted.new_finding_summaries)} new findings",
|
||||
f"{len(extracted.unresolved_finding_ids)} unresolved, "
|
||||
f"{len(new_finding_ids)} new findings, "
|
||||
f"{len(findings)} total findings reconstructed",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": [], # Full findings not recoverable via extraction
|
||||
"findings": findings,
|
||||
"resolved_ids": extracted.resolved_finding_ids,
|
||||
"unresolved_ids": extracted.unresolved_finding_ids,
|
||||
"new_finding_ids": [],
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": extracted.confirmed_finding_count,
|
||||
"dismissed_finding_count": extracted.dismissed_finding_count,
|
||||
@@ -1250,6 +1295,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
|
||||
This handles cases where the AI produced valid data but it doesn't exactly
|
||||
match the expected schema (missing optional fields, type mismatches, etc.).
|
||||
Defensively extracts findings from the raw dict so partial results are preserved.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
@@ -1257,6 +1303,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
resolved_ids = []
|
||||
unresolved_ids = []
|
||||
new_finding_ids = []
|
||||
findings = []
|
||||
|
||||
# Try to extract resolution verifications
|
||||
resolution_verifications = data.get("resolution_verifications", [])
|
||||
@@ -1275,14 +1322,68 @@ The SDK will run invoked agents in parallel automatically.
|
||||
):
|
||||
unresolved_ids.append(finding_id)
|
||||
|
||||
# Try to extract new findings
|
||||
new_findings = data.get("new_findings", [])
|
||||
if isinstance(new_findings, list):
|
||||
for nf in new_findings:
|
||||
if isinstance(nf, dict):
|
||||
finding_id = nf.get("id", "")
|
||||
if finding_id:
|
||||
new_finding_ids.append(finding_id)
|
||||
# Try to extract new findings as PRReviewFinding objects
|
||||
new_findings_raw = data.get("new_findings", [])
|
||||
if isinstance(new_findings_raw, list):
|
||||
for nf in new_findings_raw:
|
||||
if not isinstance(nf, dict):
|
||||
continue
|
||||
try:
|
||||
finding_id = nf.get("id", "") or self._generate_finding_id(
|
||||
nf.get("file", "unknown"),
|
||||
nf.get("line", 0),
|
||||
nf.get("title", "unknown"),
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(nf.get("severity", "medium")),
|
||||
category=map_category(nf.get("category", "quality")),
|
||||
title=nf.get("title", "Unknown issue"),
|
||||
description=nf.get("description", ""),
|
||||
file=nf.get("file", "unknown"),
|
||||
line=nf.get("line", 0) or 0,
|
||||
suggested_fix=nf.get("suggested_fix"),
|
||||
fixable=bool(nf.get("fixable", False)),
|
||||
is_impact_finding=bool(nf.get("is_impact_finding", False)),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[ParallelFollowup] Skipping malformed new finding: {e}"
|
||||
)
|
||||
|
||||
# Try to extract comment findings as PRReviewFinding objects
|
||||
comment_findings_raw = data.get("comment_findings", [])
|
||||
if isinstance(comment_findings_raw, list):
|
||||
for cf in comment_findings_raw:
|
||||
if not isinstance(cf, dict):
|
||||
continue
|
||||
try:
|
||||
finding_id = cf.get("id", "") or self._generate_finding_id(
|
||||
cf.get("file", "unknown"),
|
||||
cf.get("line", 0),
|
||||
cf.get("title", "unknown"),
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(cf.get("severity", "medium")),
|
||||
category=map_category(cf.get("category", "quality")),
|
||||
title=f"[FROM COMMENTS] {cf.get('title', 'Unknown issue')}",
|
||||
description=cf.get("description", ""),
|
||||
file=cf.get("file", "unknown"),
|
||||
line=cf.get("line", 0) or 0,
|
||||
suggested_fix=cf.get("suggested_fix"),
|
||||
fixable=bool(cf.get("fixable", False)),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[ParallelFollowup] Skipping malformed comment finding: {e}"
|
||||
)
|
||||
|
||||
# Try to extract verdict
|
||||
verdict_str = data.get("verdict", "NEEDS_REVISION")
|
||||
@@ -1297,14 +1398,15 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning = data.get("verdict_reasoning", "Extracted from partial data")
|
||||
|
||||
# Only return if we got any useful data
|
||||
if resolved_ids or unresolved_ids or new_finding_ids:
|
||||
if resolved_ids or unresolved_ids or new_finding_ids or findings:
|
||||
return {
|
||||
"findings": [], # Can't reliably extract full findings without validation
|
||||
"findings": findings,
|
||||
"resolved_ids": resolved_ids,
|
||||
"unresolved_ids": unresolved_ids,
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": 0,
|
||||
"dismissed_finding_count": 0,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": f"[Partial extraction] {verdict_reasoning}",
|
||||
|
||||
@@ -633,7 +633,14 @@ Report findings with specific file paths, line numbers, and code evidence.
|
||||
logger.error(
|
||||
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
|
||||
)
|
||||
# Fall through to text parsing
|
||||
# Attempt to extract findings from raw dict before falling to text parsing
|
||||
findings = self._extract_specialist_partial_data(
|
||||
specialist_name, structured_output
|
||||
)
|
||||
if findings:
|
||||
logger.info(
|
||||
f"[Specialist:{specialist_name}] Recovered {len(findings)} findings from partial extraction"
|
||||
)
|
||||
|
||||
if not findings and result_text:
|
||||
# Fallback to text parsing
|
||||
@@ -643,6 +650,63 @@ Report findings with specific file paths, line numbers, and code evidence.
|
||||
|
||||
return findings
|
||||
|
||||
def _extract_specialist_partial_data(
|
||||
self,
|
||||
specialist_name: str,
|
||||
data: dict[str, Any],
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Extract findings from raw specialist dict when Pydantic validation fails.
|
||||
|
||||
Defensively extracts each finding individually so partial results are preserved
|
||||
even if some findings have validation issues.
|
||||
"""
|
||||
findings = []
|
||||
raw_findings = data.get("findings", [])
|
||||
if not isinstance(raw_findings, list):
|
||||
return findings
|
||||
|
||||
for f in raw_findings:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
try:
|
||||
file_path = f.get("file", "unknown")
|
||||
line = f.get("line", 0) or 0
|
||||
title = f.get("title", "Unknown issue")
|
||||
|
||||
finding_id = hashlib.md5(
|
||||
f"{file_path}:{line}:{title}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(f.get("category", "quality"))
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(str(f.get("severity", "medium")).lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=file_path,
|
||||
line=line,
|
||||
end_line=f.get("end_line"),
|
||||
title=title,
|
||||
description=f.get("description", ""),
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=f.get("suggested_fix", ""),
|
||||
evidence=f.get("evidence"),
|
||||
source_agents=[specialist_name],
|
||||
is_impact_finding=bool(f.get("is_impact_finding", False)),
|
||||
)
|
||||
findings.append(finding)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[Specialist:{specialist_name}] Skipping malformed finding: {e}"
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_parallel_specialists(
|
||||
self,
|
||||
context: PRContext,
|
||||
@@ -910,13 +974,15 @@ The SDK will run invoked agents in parallel automatically.
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
# Extract evidence: prefer verification.code_examined, fallback to evidence field
|
||||
evidence = finding_data.evidence
|
||||
# Extract evidence from verification.code_examined if available
|
||||
evidence = None
|
||||
if hasattr(finding_data, "verification") and finding_data.verification:
|
||||
# Structured verification has more detailed evidence
|
||||
verification = finding_data.verification
|
||||
if hasattr(verification, "code_examined") and verification.code_examined:
|
||||
evidence = verification.code_examined
|
||||
# Fallback to evidence field if present (e.g. from dict-based parsing)
|
||||
if not evidence:
|
||||
evidence = getattr(finding_data, "evidence", None)
|
||||
|
||||
# Extract end_line if present
|
||||
end_line = getattr(finding_data, "end_line", None)
|
||||
@@ -1223,12 +1289,30 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"{len(filtered_findings)} filtered"
|
||||
)
|
||||
|
||||
# No confidence routing - validation is binary via finding-validator
|
||||
unique_findings = validated_findings
|
||||
logger.info(f"[PRReview] Final findings: {len(unique_findings)} validated")
|
||||
# Separate active findings (drive verdict) from dismissed (shown in UI only)
|
||||
active_findings = []
|
||||
dismissed_findings = []
|
||||
for f in validated_findings:
|
||||
if f.validation_status == "dismissed_false_positive":
|
||||
dismissed_findings.append(f)
|
||||
else:
|
||||
active_findings.append(f)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Final: {len(active_findings)} active, "
|
||||
f"{len(dismissed_findings)} disputed by validator",
|
||||
flush=True,
|
||||
)
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings"
|
||||
f"[PRReview] Final findings: {len(active_findings)} active, "
|
||||
f"{len(dismissed_findings)} disputed"
|
||||
)
|
||||
|
||||
# All findings (active + dismissed) go in the result for UI display
|
||||
all_review_findings = validated_findings
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Review complete: {len(all_review_findings)} findings "
|
||||
f"({len(active_findings)} active, {len(dismissed_findings)} disputed)"
|
||||
)
|
||||
|
||||
# Fetch CI status for verdict consideration
|
||||
@@ -1238,9 +1322,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending"
|
||||
)
|
||||
|
||||
# Generate verdict (includes merge conflict check, branch-behind check, and CI status)
|
||||
# Generate verdict from ACTIVE findings only (dismissed don't affect verdict)
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
unique_findings,
|
||||
active_findings,
|
||||
has_merge_conflicts=context.has_merge_conflicts,
|
||||
merge_state_status=context.merge_state_status,
|
||||
ci_status=ci_status,
|
||||
@@ -1251,7 +1335,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
findings=unique_findings,
|
||||
findings=all_review_findings,
|
||||
agents_invoked=agents_invoked,
|
||||
)
|
||||
|
||||
@@ -1296,7 +1380,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=unique_findings,
|
||||
findings=all_review_findings,
|
||||
summary=summary,
|
||||
overall_status=overall_status,
|
||||
verdict=verdict,
|
||||
@@ -1806,6 +1890,7 @@ For EACH finding above:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# Part of retry loop structure - handles retryable errors
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
@@ -1870,12 +1955,38 @@ For EACH finding above:
|
||||
validated_findings.append(finding)
|
||||
|
||||
elif validation.validation_status == "dismissed_false_positive":
|
||||
# Dismiss - do not include
|
||||
dismissed_count += 1
|
||||
logger.info(
|
||||
f"[PRReview] Dismissed {finding.id} as false positive: "
|
||||
f"{validation.explanation[:100]}"
|
||||
)
|
||||
# Protect cross-validated findings from dismissal —
|
||||
# if multiple specialists independently found the same issue,
|
||||
# a single validator should not override that consensus
|
||||
if finding.cross_validated:
|
||||
finding.validation_status = "confirmed_valid"
|
||||
finding.validation_evidence = validation.code_evidence
|
||||
finding.validation_explanation = (
|
||||
f"[Auto-kept: cross-validated by {len(finding.source_agents)} agents] "
|
||||
f"{validation.explanation}"
|
||||
)
|
||||
validated_findings.append(finding)
|
||||
safe_print(
|
||||
f"[FindingValidator] Kept cross-validated finding '{finding.title}' "
|
||||
f"despite dismissal (agents={finding.source_agents})",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
# Keep finding but mark as dismissed (user can see it in UI)
|
||||
finding.validation_status = "dismissed_false_positive"
|
||||
finding.validation_evidence = validation.code_evidence
|
||||
finding.validation_explanation = validation.explanation
|
||||
validated_findings.append(finding)
|
||||
dismissed_count += 1
|
||||
safe_print(
|
||||
f"[FindingValidator] Disputed '{finding.title}': "
|
||||
f"{validation.explanation} (file={finding.file}:{finding.line})",
|
||||
flush=True,
|
||||
)
|
||||
logger.info(
|
||||
f"[PRReview] Disputed {finding.id}: "
|
||||
f"{validation.explanation[:200]}"
|
||||
)
|
||||
|
||||
elif validation.validation_status == "needs_human_review":
|
||||
# Keep but flag
|
||||
@@ -2060,11 +2171,16 @@ For EACH finding above:
|
||||
sev = f.severity.value
|
||||
emoji = severity_emoji.get(sev, "⚪")
|
||||
|
||||
is_disputed = f.validation_status == "dismissed_false_positive"
|
||||
|
||||
# Finding header with location
|
||||
line_range = f"L{f.line}"
|
||||
if f.end_line and f.end_line != f.line:
|
||||
line_range = f"L{f.line}-L{f.end_line}"
|
||||
lines.append(f"#### {emoji} [{sev.upper()}] {f.title}")
|
||||
if is_disputed:
|
||||
lines.append(f"#### ⚪ [DISPUTED] ~~{f.title}~~")
|
||||
else:
|
||||
lines.append(f"#### {emoji} [{sev.upper()}] {f.title}")
|
||||
lines.append(f"**File:** `{f.file}` ({line_range})")
|
||||
|
||||
# Cross-validation badge
|
||||
@@ -2094,6 +2210,7 @@ For EACH finding above:
|
||||
status_label = {
|
||||
"confirmed_valid": "Confirmed",
|
||||
"needs_human_review": "Needs human review",
|
||||
"dismissed_false_positive": "Disputed by validator",
|
||||
}.get(f.validation_status, f.validation_status)
|
||||
lines.append("")
|
||||
lines.append(f"**Validation:** {status_label}")
|
||||
@@ -2115,18 +2232,27 @@ For EACH finding above:
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Findings count summary
|
||||
# Findings count summary (exclude dismissed from active count)
|
||||
active_count = 0
|
||||
dismissed_count = 0
|
||||
by_severity: dict[str, int] = {}
|
||||
for f in findings:
|
||||
if f.validation_status == "dismissed_false_positive":
|
||||
dismissed_count += 1
|
||||
continue
|
||||
active_count += 1
|
||||
sev = f.severity.value
|
||||
by_severity[sev] = by_severity.get(sev, 0) + 1
|
||||
summary_parts = []
|
||||
for sev in ["critical", "high", "medium", "low"]:
|
||||
if sev in by_severity:
|
||||
summary_parts.append(f"{by_severity[sev]} {sev}")
|
||||
lines.append(
|
||||
f"**Total:** {len(findings)} finding(s) ({', '.join(summary_parts)})"
|
||||
count_text = (
|
||||
f"**Total:** {active_count} finding(s) ({', '.join(summary_parts)})"
|
||||
)
|
||||
if dismissed_count > 0:
|
||||
count_text += f" + {dismissed_count} disputed"
|
||||
lines.append(count_text)
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
|
||||
@@ -26,10 +26,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# =============================================================================
|
||||
# Verification Evidence (Required for All Findings)
|
||||
# Verification Evidence (Optional for findings — only code_examined is consumed)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -50,102 +50,28 @@ class VerificationEvidence(BaseModel):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Common Finding Types
|
||||
# Severity / Category Validators
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BaseFinding(BaseModel):
|
||||
"""Base class for all finding types."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
evidence: str | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
_VALID_SEVERITIES = {"critical", "high", "medium", "low"}
|
||||
|
||||
|
||||
class SecurityFinding(BaseFinding):
|
||||
"""A security vulnerability finding."""
|
||||
|
||||
category: Literal["security"] = Field(
|
||||
default="security", description="Always 'security' for security findings"
|
||||
)
|
||||
def _normalize_severity(v: str) -> str:
|
||||
"""Normalize severity to a valid value, defaulting to 'medium'."""
|
||||
if isinstance(v, str):
|
||||
v = v.lower().strip()
|
||||
if v not in _VALID_SEVERITIES:
|
||||
return "medium"
|
||||
return v
|
||||
|
||||
|
||||
class QualityFinding(BaseFinding):
|
||||
"""A code quality or redundancy finding."""
|
||||
|
||||
category: Literal[
|
||||
"redundancy", "quality", "test", "performance", "pattern", "docs"
|
||||
] = Field(description="Issue category")
|
||||
redundant_with: str | None = Field(
|
||||
None, description="Reference to duplicate code (file:line) if redundant"
|
||||
)
|
||||
|
||||
|
||||
class DeepAnalysisFinding(BaseFinding):
|
||||
"""A finding from deep analysis with verification info."""
|
||||
|
||||
category: Literal[
|
||||
"verification_failed",
|
||||
"redundancy",
|
||||
"quality",
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
] = Field(description="Issue category")
|
||||
verification_note: str | None = Field(
|
||||
None, description="What evidence is missing or couldn't be verified"
|
||||
)
|
||||
|
||||
|
||||
class StructuralIssue(BaseModel):
|
||||
"""A structural issue with the PR."""
|
||||
|
||||
id: str = Field(description="Unique identifier")
|
||||
issue_type: Literal[
|
||||
"feature_creep", "scope_creep", "architecture_violation", "poor_structure"
|
||||
] = Field(description="Type of structural issue")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity"
|
||||
)
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation")
|
||||
impact: str = Field(description="Why this matters")
|
||||
suggestion: str = Field(description="How to fix")
|
||||
|
||||
|
||||
class AICommentTriage(BaseModel):
|
||||
"""Triage result for an AI tool comment."""
|
||||
|
||||
comment_id: int = Field(description="GitHub comment ID")
|
||||
tool_name: str = Field(
|
||||
description="AI tool name (CodeRabbit, Cursor, Greptile, etc.)"
|
||||
)
|
||||
verdict: Literal[
|
||||
"critical",
|
||||
"important",
|
||||
"nice_to_have",
|
||||
"trivial",
|
||||
"addressed",
|
||||
"false_positive",
|
||||
] = Field(description="Verdict on the comment")
|
||||
reasoning: str = Field(description="Why this verdict was chosen")
|
||||
response_comment: str | None = Field(
|
||||
None, description="Optional comment to post in reply"
|
||||
)
|
||||
def _normalize_category(v: str, valid_set: set[str], default: str = "quality") -> str:
|
||||
"""Normalize category to a valid value, defaulting to given default."""
|
||||
if isinstance(v, str):
|
||||
v = v.lower().strip().replace("-", "_")
|
||||
if v not in valid_set:
|
||||
return default
|
||||
return v
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -163,25 +89,34 @@ class FindingResolution(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_FOLLOWUP_CATEGORIES = {"security", "quality", "logic", "test", "docs"}
|
||||
|
||||
|
||||
class FollowupFinding(BaseModel):
|
||||
"""A new finding from follow-up review (simpler than initial review)."""
|
||||
"""A new finding from follow-up review (simpler than initial review).
|
||||
|
||||
verification is intentionally omitted — not consumed by followup_reviewer.py.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
|
||||
description="Issue category"
|
||||
)
|
||||
severity: str = Field(description="Issue severity level")
|
||||
category: str = Field(description="Issue category")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _FOLLOWUP_CATEGORIES)
|
||||
|
||||
|
||||
class FollowupReviewResponse(BaseModel):
|
||||
@@ -203,81 +138,6 @@ class FollowupReviewResponse(BaseModel):
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Review Responses (Multi-Pass)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class QuickScanResult(BaseModel):
|
||||
"""Result from the quick scan pass."""
|
||||
|
||||
purpose: str = Field(description="Brief description of what the PR claims to do")
|
||||
actual_changes: str = Field(
|
||||
description="Brief description of what the code actually does"
|
||||
)
|
||||
purpose_match: bool = Field(
|
||||
description="Whether actual changes match the claimed purpose"
|
||||
)
|
||||
purpose_match_note: str | None = Field(
|
||||
None, description="Explanation if purpose doesn't match actual changes"
|
||||
)
|
||||
risk_areas: list[str] = Field(
|
||||
default_factory=list, description="Areas needing careful review"
|
||||
)
|
||||
red_flags: list[str] = Field(
|
||||
default_factory=list, description="Obvious issues or concerns"
|
||||
)
|
||||
requires_deep_verification: bool = Field(
|
||||
description="Whether deep verification is needed"
|
||||
)
|
||||
complexity: Literal["low", "medium", "high"] = Field(description="PR complexity")
|
||||
|
||||
|
||||
class SecurityPassResult(BaseModel):
|
||||
"""Result from the security pass - array of security findings."""
|
||||
|
||||
findings: list[SecurityFinding] = Field(
|
||||
default_factory=list, description="Security vulnerabilities found"
|
||||
)
|
||||
|
||||
|
||||
class QualityPassResult(BaseModel):
|
||||
"""Result from the quality pass - array of quality findings."""
|
||||
|
||||
findings: list[QualityFinding] = Field(
|
||||
default_factory=list, description="Quality and redundancy issues found"
|
||||
)
|
||||
|
||||
|
||||
class DeepAnalysisResult(BaseModel):
|
||||
"""Result from the deep analysis pass."""
|
||||
|
||||
findings: list[DeepAnalysisFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Deep analysis findings with verification info",
|
||||
)
|
||||
|
||||
|
||||
class StructuralPassResult(BaseModel):
|
||||
"""Result from the structural pass."""
|
||||
|
||||
issues: list[StructuralIssue] = Field(
|
||||
default_factory=list, description="Structural issues found"
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Structural verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
class AICommentTriageResult(BaseModel):
|
||||
"""Result from AI comment triage pass."""
|
||||
|
||||
triages: list[AICommentTriage] = Field(
|
||||
default_factory=list, description="Triage results for each AI comment"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Issue Triage Response
|
||||
# =============================================================================
|
||||
@@ -320,88 +180,21 @@ class IssueTriageResponse(BaseModel):
|
||||
comment: str | None = Field(None, description="Optional bot comment to post")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Orchestrator Review Response
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrchestratorFinding(BaseModel):
|
||||
"""A finding from the orchestrator review."""
|
||||
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"style",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"verification_failed",
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
"test",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
suggestion: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorReviewResponse(BaseModel):
|
||||
"""Complete response schema for orchestrator PR review."""
|
||||
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
findings: list[OrchestratorFinding] = Field(
|
||||
default_factory=list, description="Issues found during review"
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the review")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Orchestrator Review Response (SDK Subagents)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LogicFinding(BaseFinding):
|
||||
"""A logic/correctness finding from the logic review agent."""
|
||||
|
||||
category: Literal["logic"] = Field(
|
||||
default="logic", description="Always 'logic' for logic findings"
|
||||
)
|
||||
example_input: str | None = Field(
|
||||
None, description="Concrete input that triggers the bug"
|
||||
)
|
||||
actual_output: str | None = Field(None, description="What the buggy code produces")
|
||||
expected_output: str | None = Field(
|
||||
None, description="What the code should produce"
|
||||
)
|
||||
|
||||
|
||||
class CodebaseFitFinding(BaseFinding):
|
||||
"""A codebase fit finding from the codebase fit review agent."""
|
||||
|
||||
category: Literal["codebase_fit"] = Field(
|
||||
default="codebase_fit", description="Always 'codebase_fit' for fit findings"
|
||||
)
|
||||
existing_code: str | None = Field(
|
||||
None, description="Reference to existing code that should be used instead"
|
||||
)
|
||||
codebase_pattern: str | None = Field(
|
||||
None, description="Description of the established pattern being violated"
|
||||
)
|
||||
_ORCHESTRATOR_CATEGORIES = {
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"codebase_fit",
|
||||
"test",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
}
|
||||
|
||||
|
||||
class ParallelOrchestratorFinding(BaseModel):
|
||||
@@ -413,26 +206,11 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"codebase_fit",
|
||||
"test",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
evidence: str | None = Field(
|
||||
category: str = Field(description="Issue category")
|
||||
severity: str = Field(description="Issue severity level")
|
||||
verification: VerificationEvidence | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
description="Evidence that this finding was verified against actual code",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
@@ -459,6 +237,16 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
False, description="Whether multiple agents agreed on this finding"
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _ORCHESTRATOR_CATEGORIES)
|
||||
|
||||
|
||||
class AgentAgreement(BaseModel):
|
||||
"""Tracks agreement between agents on findings."""
|
||||
@@ -514,15 +302,22 @@ class ValidationSummary(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_SPECIALIST_CATEGORIES = {
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"performance",
|
||||
"pattern",
|
||||
"test",
|
||||
"docs",
|
||||
}
|
||||
|
||||
|
||||
class SpecialistFinding(BaseModel):
|
||||
"""A finding from a specialist agent (used in parallel SDK sessions)."""
|
||||
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal[
|
||||
"security", "quality", "logic", "performance", "pattern", "test", "docs"
|
||||
] = Field(description="Issue category")
|
||||
severity: str = Field(description="Issue severity level")
|
||||
category: str = Field(description="Issue category")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
@@ -530,14 +325,24 @@ class SpecialistFinding(BaseModel):
|
||||
end_line: int | None = Field(None, description="End line number if multi-line")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str = Field(
|
||||
min_length=1,
|
||||
description="Actual code snippet examined that shows the issue. Required.",
|
||||
default="",
|
||||
description="Actual code snippet examined that shows the issue.",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
description="True if this is about affected code outside the PR (callers, dependencies)",
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _SPECIALIST_CATEGORIES)
|
||||
|
||||
|
||||
class SpecialistResponse(BaseModel):
|
||||
"""Response schema for individual specialist agent (parallel SDK sessions).
|
||||
@@ -611,6 +416,17 @@ class ResolutionVerification(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_PARALLEL_FOLLOWUP_CATEGORIES = {
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"test",
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
}
|
||||
|
||||
|
||||
class ParallelFollowupFinding(BaseModel):
|
||||
"""A finding from parallel follow-up review."""
|
||||
|
||||
@@ -619,18 +435,8 @@ class ParallelFollowupFinding(BaseModel):
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"test",
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: str = Field(description="Issue category")
|
||||
severity: str = Field(description="Issue severity level")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
is_impact_finding: bool = Field(
|
||||
@@ -638,6 +444,16 @@ class ParallelFollowupFinding(BaseModel):
|
||||
description="True if this finding is about impact on OTHER files outside the PR diff",
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _PARALLEL_FOLLOWUP_CATEGORIES)
|
||||
|
||||
|
||||
class ParallelFollowupResponse(BaseModel):
|
||||
"""Complete response schema for parallel follow-up PR review.
|
||||
@@ -717,10 +533,26 @@ class FindingValidationResponse(BaseModel):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ExtractedFindingSummary(BaseModel):
|
||||
"""Per-finding summary with file location for extraction recovery."""
|
||||
|
||||
severity: str = Field(description="Severity level: LOW, MEDIUM, HIGH, or CRITICAL")
|
||||
description: str = Field(description="One-line description of the finding")
|
||||
file: str = Field(
|
||||
default="unknown", description="File path where the issue was found"
|
||||
)
|
||||
line: int = Field(default=0, description="Line number in the file (0 if unknown)")
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
|
||||
class FollowupExtractionResponse(BaseModel):
|
||||
"""Minimal extraction schema for recovering data when full structured output fails.
|
||||
|
||||
Deliberately kept small (~6 fields, no nesting) for near-100% validation success.
|
||||
Uses ExtractedFindingSummary for new findings to preserve file/line information.
|
||||
Used as an intermediate recovery step before falling back to raw text parsing.
|
||||
"""
|
||||
|
||||
@@ -736,9 +568,9 @@ class FollowupExtractionResponse(BaseModel):
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that remain unresolved",
|
||||
)
|
||||
new_finding_summaries: list[str] = Field(
|
||||
new_finding_summaries: list[ExtractedFindingSummary] = Field(
|
||||
default_factory=list,
|
||||
description="One-line summary of each new finding (e.g. 'HIGH: cleanup deletes QA-rejected specs in batch_commands.py')",
|
||||
description="Structured summary of each new finding with file location",
|
||||
)
|
||||
confirmed_finding_count: int = Field(
|
||||
0, description="Number of findings confirmed as valid"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Recovery Utilities for PR Review
|
||||
=================================
|
||||
|
||||
Shared helpers for extraction recovery in followup and parallel followup reviewers.
|
||||
|
||||
These utilities consolidate duplicated logic for:
|
||||
- Parsing "SEVERITY: description" patterns from extraction summaries
|
||||
- Generating consistent, traceable finding IDs with prefixes
|
||||
- Creating PRReviewFinding objects from extraction data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
from ..models import (
|
||||
PRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import (
|
||||
PRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
# Severity mapping for parsing "SEVERITY: description" patterns
|
||||
_EXTRACTION_SEVERITY_MAP: list[tuple[str, ReviewSeverity]] = [
|
||||
("CRITICAL:", ReviewSeverity.CRITICAL),
|
||||
("HIGH:", ReviewSeverity.HIGH),
|
||||
("MEDIUM:", ReviewSeverity.MEDIUM),
|
||||
("LOW:", ReviewSeverity.LOW),
|
||||
]
|
||||
|
||||
|
||||
def parse_severity_from_summary(
|
||||
summary: str,
|
||||
) -> tuple[ReviewSeverity, str]:
|
||||
"""Parse a "SEVERITY: description" pattern from an extraction summary.
|
||||
|
||||
Args:
|
||||
summary: Raw summary string, e.g. "HIGH: Missing null check in parser.py"
|
||||
|
||||
Returns:
|
||||
Tuple of (severity, cleaned_description).
|
||||
Defaults to MEDIUM severity if no prefix is found.
|
||||
"""
|
||||
upper_summary = summary.upper()
|
||||
for sev_name, sev_val in _EXTRACTION_SEVERITY_MAP:
|
||||
if upper_summary.startswith(sev_name):
|
||||
return sev_val, summary[len(sev_name) :].strip()
|
||||
return ReviewSeverity.MEDIUM, summary
|
||||
|
||||
|
||||
def generate_recovery_finding_id(
|
||||
index: int, description: str, prefix: str = "FR"
|
||||
) -> str:
|
||||
"""Generate a consistent, traceable finding ID for recovery findings.
|
||||
|
||||
Args:
|
||||
index: The index of the finding in the extraction list.
|
||||
description: The finding description (used for hash uniqueness).
|
||||
prefix: ID prefix for traceability. Default "FR" (Followup Recovery).
|
||||
Use "FU" for parallel followup findings.
|
||||
|
||||
Returns:
|
||||
A prefixed finding ID like "FR-A1B2C3D4" or "FU-A1B2C3D4".
|
||||
"""
|
||||
content = f"extraction-{index}-{description}"
|
||||
hex_hash = (
|
||||
hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8].upper()
|
||||
)
|
||||
return f"{prefix}-{hex_hash}"
|
||||
|
||||
|
||||
def create_finding_from_summary(
|
||||
summary: str,
|
||||
index: int,
|
||||
id_prefix: str = "FR",
|
||||
severity_override: str | None = None,
|
||||
file: str = "unknown",
|
||||
line: int = 0,
|
||||
) -> PRReviewFinding:
|
||||
"""Create a PRReviewFinding from an extraction summary string.
|
||||
|
||||
Parses "SEVERITY: description" patterns, generates a traceable finding ID,
|
||||
and returns a fully constructed PRReviewFinding.
|
||||
|
||||
Args:
|
||||
summary: Raw summary string, e.g. "HIGH: Missing null check in parser.py"
|
||||
index: The index of the finding in the extraction list.
|
||||
id_prefix: ID prefix for traceability. Default "FR" (Followup Recovery).
|
||||
severity_override: If provided, use this severity instead of parsing from summary.
|
||||
file: File path where the issue was found (default "unknown").
|
||||
line: Line number in the file (default 0).
|
||||
|
||||
Returns:
|
||||
A PRReviewFinding with parsed severity, generated ID, and description.
|
||||
"""
|
||||
severity, description = parse_severity_from_summary(summary)
|
||||
|
||||
# Use severity_override if provided
|
||||
if severity_override is not None:
|
||||
severity_map = {k.rstrip(":"): v for k, v in _EXTRACTION_SEVERITY_MAP}
|
||||
severity = severity_map.get(severity_override.upper(), severity)
|
||||
|
||||
finding_id = generate_recovery_finding_id(index, description, prefix=id_prefix)
|
||||
|
||||
return PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=severity,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title=description[:80],
|
||||
description=f"[Recovered via extraction] {description}",
|
||||
file=file,
|
||||
line=line,
|
||||
)
|
||||
@@ -14,12 +14,19 @@ Key Features:
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
# Recovery manager configuration
|
||||
ATTEMPT_WINDOW_SECONDS = 7200 # Only count attempts within last 2 hours
|
||||
MAX_ATTEMPT_HISTORY_PER_SUBTASK = 50 # Cap stored attempts per subtask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FailureType(Enum):
|
||||
"""Types of failures that can occur during autonomous builds."""
|
||||
@@ -82,8 +89,8 @@ class RecoveryManager:
|
||||
"subtasks": {},
|
||||
"stuck_subtasks": [],
|
||||
"metadata": {
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
with open(self.attempt_history_file, "w", encoding="utf-8") as f:
|
||||
@@ -95,8 +102,8 @@ class RecoveryManager:
|
||||
"commits": [],
|
||||
"last_good_commit": None,
|
||||
"metadata": {
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
with open(self.build_commits_file, "w", encoding="utf-8") as f:
|
||||
@@ -114,7 +121,7 @@ class RecoveryManager:
|
||||
|
||||
def _save_attempt_history(self, data: dict) -> None:
|
||||
"""Save attempt history to JSON file."""
|
||||
data["metadata"]["last_updated"] = datetime.now().isoformat()
|
||||
data["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(self.attempt_history_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@@ -130,7 +137,7 @@ class RecoveryManager:
|
||||
|
||||
def _save_build_commits(self, data: dict) -> None:
|
||||
"""Save build commits to JSON file."""
|
||||
data["metadata"]["last_updated"] = datetime.now().isoformat()
|
||||
data["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(self.build_commits_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@@ -185,17 +192,44 @@ class RecoveryManager:
|
||||
|
||||
def get_attempt_count(self, subtask_id: str) -> int:
|
||||
"""
|
||||
Get how many times this subtask has been attempted.
|
||||
Get how many times this subtask has been attempted within the time window.
|
||||
|
||||
Only counts attempts within ATTEMPT_WINDOW_SECONDS (default: 2 hours).
|
||||
This prevents unbounded accumulation across crash/restart cycles.
|
||||
|
||||
Args:
|
||||
subtask_id: ID of the subtask
|
||||
|
||||
Returns:
|
||||
Number of attempts
|
||||
Number of attempts within the time window
|
||||
"""
|
||||
history = self._load_attempt_history()
|
||||
subtask_data = history["subtasks"].get(subtask_id, {})
|
||||
return len(subtask_data.get("attempts", []))
|
||||
attempts = subtask_data.get("attempts", [])
|
||||
|
||||
# Calculate cutoff time for the window
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=ATTEMPT_WINDOW_SECONDS
|
||||
)
|
||||
# For backward compatibility with naive timestamps, also create naive cutoff
|
||||
cutoff_time_naive = datetime.now() - timedelta(seconds=ATTEMPT_WINDOW_SECONDS)
|
||||
|
||||
# Count only attempts within the time window
|
||||
recent_count = 0
|
||||
for attempt in attempts:
|
||||
try:
|
||||
attempt_time = datetime.fromisoformat(attempt["timestamp"])
|
||||
# Use appropriate cutoff based on whether timestamp is naive or aware
|
||||
cutoff = (
|
||||
cutoff_time_naive if attempt_time.tzinfo is None else cutoff_time
|
||||
)
|
||||
if attempt_time >= cutoff:
|
||||
recent_count += 1
|
||||
except (KeyError, ValueError):
|
||||
# If timestamp is missing or invalid, count it (backward compatibility)
|
||||
recent_count += 1
|
||||
|
||||
return recent_count
|
||||
|
||||
def record_attempt(
|
||||
self,
|
||||
@@ -208,6 +242,8 @@ class RecoveryManager:
|
||||
"""
|
||||
Record an attempt at a subtask.
|
||||
|
||||
Automatically trims old attempts if the history exceeds MAX_ATTEMPT_HISTORY_PER_SUBTASK.
|
||||
|
||||
Args:
|
||||
subtask_id: ID of the subtask
|
||||
session: Session number
|
||||
@@ -224,13 +260,24 @@ class RecoveryManager:
|
||||
# Add the attempt
|
||||
attempt = {
|
||||
"session": session,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"approach": approach,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}
|
||||
history["subtasks"][subtask_id]["attempts"].append(attempt)
|
||||
|
||||
# Hard cap: trim oldest attempts if we exceed the maximum
|
||||
attempts = history["subtasks"][subtask_id]["attempts"]
|
||||
if len(attempts) > MAX_ATTEMPT_HISTORY_PER_SUBTASK:
|
||||
trimmed_count = len(attempts) - MAX_ATTEMPT_HISTORY_PER_SUBTASK
|
||||
history["subtasks"][subtask_id]["attempts"] = attempts[
|
||||
-MAX_ATTEMPT_HISTORY_PER_SUBTASK:
|
||||
]
|
||||
logger.debug(
|
||||
f"Trimmed {trimmed_count} old attempts for subtask {subtask_id} (cap: {MAX_ATTEMPT_HISTORY_PER_SUBTASK})"
|
||||
)
|
||||
|
||||
# Update status
|
||||
if success:
|
||||
history["subtasks"][subtask_id]["status"] = "completed"
|
||||
@@ -405,7 +452,7 @@ class RecoveryManager:
|
||||
commit_record = {
|
||||
"hash": commit_hash,
|
||||
"subtask_id": subtask_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
commits["commits"].append(commit_record)
|
||||
@@ -450,7 +497,7 @@ class RecoveryManager:
|
||||
stuck_entry = {
|
||||
"subtask_id": subtask_id,
|
||||
"reason": reason,
|
||||
"escalated_at": datetime.now().isoformat(),
|
||||
"escalated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"attempt_count": self.get_attempt_count(subtask_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.6-beta.4",
|
||||
"version": "2.7.6-beta.5",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
|
||||
@@ -1012,3 +1012,115 @@ Please add credits to continue.`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCleanProfileEnv', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with CLAUDE_CONFIG_DIR set', () => {
|
||||
it('should preserve CLAUDE_CONFIG_DIR while clearing CLAUDE_CODE_OAUTH_TOKEN', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-key-456'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
|
||||
it('should preserve other environment variables', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token',
|
||||
ANTHROPIC_API_KEY: 'key',
|
||||
SOME_OTHER_VAR: 'value'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1');
|
||||
expect(result.SOME_OTHER_VAR).toBe('value');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
|
||||
it('should clear tokens even if they are not present in input', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without CLAUDE_CONFIG_DIR', () => {
|
||||
it('should return env unchanged when CLAUDE_CONFIG_DIR is not set', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-key-456'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result).toEqual(env);
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-123');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('sk-ant-key-456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty profile env', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const result = ensureCleanProfileEnv({});
|
||||
|
||||
// Empty env has no CLAUDE_CONFIG_DIR, so should return as-is
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle env with empty string CLAUDE_CONFIG_DIR', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
// Empty string is falsy, so should not trigger clearing
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
|
||||
});
|
||||
|
||||
it('should return a new object when clearing (not mutate input)', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
// Original should not be mutated
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result).not.toBe(env);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -799,4 +799,127 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||
expect(envArg.GITHUB_CLI_PATH).toBe('/opt/homebrew/bin/gh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDE_CONFIG_DIR Propagation', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should propagate CLAUDE_CONFIG_DIR from profile env in OAuth mode', async () => {
|
||||
// OAuth mode - no active API profile
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Profile provides CLAUDE_CONFIG_DIR (OAuth subscription profile)
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-abc'
|
||||
},
|
||||
profileId: 'profile-1',
|
||||
profileName: 'Profile 1',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// CLAUDE_CONFIG_DIR should be present in spawn env
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-1');
|
||||
});
|
||||
|
||||
it('should clear ANTHROPIC_API_KEY in OAuth mode with CLAUDE_CONFIG_DIR', async () => {
|
||||
// Simulate stale ANTHROPIC_API_KEY in process.env
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-stale-key';
|
||||
|
||||
// OAuth mode - no active API profile
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Profile provides CLAUDE_CONFIG_DIR
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-2',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-def'
|
||||
},
|
||||
profileId: 'profile-2',
|
||||
profileName: 'Profile 2',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// ANTHROPIC_API_KEY should be cleared (empty string) in OAuth mode
|
||||
expect(envArg.ANTHROPIC_API_KEY).toBe('');
|
||||
// CLAUDE_CONFIG_DIR should still be set
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-2');
|
||||
});
|
||||
|
||||
it('should pass ANTHROPIC_* vars without CLAUDE_CONFIG_DIR interference in API profile mode', async () => {
|
||||
// API Profile mode - active profile with custom endpoint
|
||||
const mockApiProfileEnv = {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-api-profile-key',
|
||||
ANTHROPIC_BASE_URL: 'https://custom-api.example.com',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929'
|
||||
};
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
|
||||
|
||||
// Profile env without CLAUDE_CONFIG_DIR (API profile mode)
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {},
|
||||
profileId: 'api-profile-1',
|
||||
profileName: 'Custom API',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// ANTHROPIC_* vars from API profile should be passed through
|
||||
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-api-profile-key');
|
||||
expect(envArg.ANTHROPIC_BASE_URL).toBe('https://custom-api.example.com');
|
||||
expect(envArg.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929');
|
||||
|
||||
// CLAUDE_CONFIG_DIR should NOT be present since profile didn't provide it
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear CLAUDE_CODE_OAUTH_TOKEN when CLAUDE_CONFIG_DIR is provided by profile', async () => {
|
||||
// OAuth mode
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Profile provides CLAUDE_CONFIG_DIR - agent should use config dir for auth
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-3',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-ghi'
|
||||
},
|
||||
profileId: 'profile-3',
|
||||
profileName: 'Profile 3',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// When CLAUDE_CONFIG_DIR is present, CLAUDE_CODE_OAUTH_TOKEN should be cleared
|
||||
// because Claude Code resolves auth from the config dir instead
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-3');
|
||||
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { getOAuthModeClearVars } from './env-utils';
|
||||
import { getAugmentedEnv } from '../env-utils';
|
||||
import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager';
|
||||
import { killProcessGracefully, isWindows } from '../platform';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Type for supported CLI tools
|
||||
@@ -178,6 +179,29 @@ export class AgentProcessManager {
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
debugLog('[AgentProcess:setupEnv] Profile result:', {
|
||||
profileId: profileResult.profileId,
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!profileEnv.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
||||
configDir: profileEnv.CLAUDE_CONFIG_DIR || '(not set)',
|
||||
oauthTokenPrefix: profileEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 8) || '(not set)',
|
||||
apiKeyPrefix: profileEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)',
|
||||
});
|
||||
|
||||
// Warn if profile lacks CLAUDE_CONFIG_DIR - this means the profile has no configDir
|
||||
// and subscription metadata may not propagate correctly to the agent subprocess
|
||||
if (!profileEnv.CLAUDE_CONFIG_DIR) {
|
||||
console.warn('[AgentProcess:setupEnv] WARNING: Profile env lacks CLAUDE_CONFIG_DIR - profile may not have a configDir set. Subscription metadata may not reach agent subprocess.');
|
||||
}
|
||||
|
||||
debugLog('[AgentProcess:setupEnv] extraEnv auth keys:', {
|
||||
hasOAuthToken: !!extraEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!extraEnv.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!extraEnv.CLAUDE_CONFIG_DIR,
|
||||
});
|
||||
|
||||
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
|
||||
// are available even when app is launched from Finder/Dock
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
@@ -205,7 +229,9 @@ export class AgentProcessManager {
|
||||
const ghCliEnv = this.detectAndSetCliPath('gh');
|
||||
const glabCliEnv = this.detectAndSetCliPath('glab');
|
||||
|
||||
return {
|
||||
// Profile env is spread last to ensure CLAUDE_CONFIG_DIR and auth vars
|
||||
// from the active profile always win over extraEnv or augmentedEnv.
|
||||
const mergedEnv = {
|
||||
...augmentedEnv,
|
||||
...gitBashEnv,
|
||||
...claudeCliEnv,
|
||||
@@ -217,6 +243,29 @@ export class AgentProcessManager {
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
// When the active profile provides CLAUDE_CONFIG_DIR, clear CLAUDE_CODE_OAUTH_TOKEN
|
||||
// from the spawn environment. CLAUDE_CONFIG_DIR lets Claude Code resolve its own
|
||||
// OAuth tokens from the config directory, making an explicit token unnecessary.
|
||||
// This matches the terminal pattern in claude-integration-handler.ts where
|
||||
// configDir is preferred over direct token injection.
|
||||
// We check profileEnv specifically (not mergedEnv) to avoid clearing the token
|
||||
// when CLAUDE_CONFIG_DIR comes from the shell environment rather than the profile.
|
||||
if (profileEnv.CLAUDE_CONFIG_DIR) {
|
||||
mergedEnv.CLAUDE_CODE_OAUTH_TOKEN = '';
|
||||
debugLog('[AgentProcess:setupEnv] Profile provides CLAUDE_CONFIG_DIR, cleared CLAUDE_CODE_OAUTH_TOKEN from spawn env');
|
||||
}
|
||||
|
||||
debugLog('[AgentProcess:setupEnv] Final merged env auth state:', {
|
||||
hasOAuthToken: !!mergedEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!mergedEnv.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!mergedEnv.CLAUDE_CONFIG_DIR,
|
||||
configDir: mergedEnv.CLAUDE_CONFIG_DIR || '(not set)',
|
||||
oauthTokenPrefix: mergedEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 8) || '(not set)',
|
||||
apiKeyPrefix: mergedEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)',
|
||||
});
|
||||
|
||||
return mergedEnv;
|
||||
}
|
||||
|
||||
private handleProcessFailure(
|
||||
@@ -615,6 +664,21 @@ export class AgentProcessManager {
|
||||
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
|
||||
debugLog('[AgentProcess:spawnProcess] Environment merge chain for task:', taskId, {
|
||||
baseEnv: {
|
||||
hasOAuthToken: !!env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!env.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!env.CLAUDE_CONFIG_DIR,
|
||||
configDir: env.CLAUDE_CONFIG_DIR || '(not set)',
|
||||
},
|
||||
oauthModeClearVars: Object.keys(oauthModeClearVars),
|
||||
apiProfileEnv: {
|
||||
hasApiKey: !!apiProfileEnv.ANTHROPIC_API_KEY,
|
||||
hasBaseUrl: !!apiProfileEnv.ANTHROPIC_BASE_URL,
|
||||
apiKeyPrefix: apiProfileEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)',
|
||||
},
|
||||
});
|
||||
|
||||
// Parse Python commandto handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
|
||||
let childProcess;
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
expandHomePath,
|
||||
getEmailFromConfigDir
|
||||
} from './claude-profile/profile-utils';
|
||||
import { debugLog } from '../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Manages Claude Code profiles for multi-account support.
|
||||
@@ -86,6 +87,8 @@ export class ClaudeProfileManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ClaudeProfileManager] Starting initialization...');
|
||||
|
||||
// Ensure directory exists (async) - mkdir with recursive:true is idempotent
|
||||
await mkdir(this.configDir, { recursive: true });
|
||||
|
||||
@@ -93,6 +96,9 @@ export class ClaudeProfileManager {
|
||||
const loadedData = await loadProfileStoreAsync(this.storePath);
|
||||
if (loadedData) {
|
||||
this.data = loadedData;
|
||||
debugLog('[ClaudeProfileManager] Loaded profile store with', this.data.profiles.length, 'profiles');
|
||||
} else {
|
||||
debugLog('[ClaudeProfileManager] No existing profile store found, using defaults');
|
||||
}
|
||||
|
||||
// Run one-time migration to fix corrupted emails
|
||||
@@ -104,6 +110,7 @@ export class ClaudeProfileManager {
|
||||
this.populateSubscriptionMetadata();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[ClaudeProfileManager] Initialization complete');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,13 +156,20 @@ export class ClaudeProfileManager {
|
||||
private populateSubscriptionMetadata(): void {
|
||||
let needsSave = false;
|
||||
|
||||
debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: checking', this.data.profiles.length, 'profiles');
|
||||
|
||||
for (const profile of this.data.profiles) {
|
||||
if (!profile.configDir) {
|
||||
debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: skipping profile', profile.id, '(no configDir)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if profile already has subscription metadata
|
||||
if (profile.subscriptionType && profile.rateLimitTier) {
|
||||
debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: profile', profile.id, 'already has metadata:', {
|
||||
subscriptionType: profile.subscriptionType,
|
||||
rateLimitTier: profile.rateLimitTier
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -542,8 +556,27 @@ export class ClaudeProfileManager {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
} else if (profile) {
|
||||
// Fallback: retrieve OAuth token directly from Keychain when configDir is missing.
|
||||
// Without configDir, Claude CLI cannot resolve credentials automatically,
|
||||
// so we inject CLAUDE_CODE_OAUTH_TOKEN as a direct override.
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] Profile has no configDir configured:',
|
||||
profile.name,
|
||||
'- falling back to Keychain token lookup. Subscription display may be degraded.'
|
||||
);
|
||||
|
||||
const credentials = getCredentialsFromKeychain(undefined, true);
|
||||
if (credentials.token) {
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token;
|
||||
debugLog('[ClaudeProfileManager] Injected CLAUDE_CODE_OAUTH_TOKEN from Keychain for profile:', profile.name);
|
||||
} else {
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] No token found in Keychain for profile without configDir:',
|
||||
profile.name,
|
||||
credentials.error ? `(error: ${credentials.error})` : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -801,8 +834,26 @@ export class ClaudeProfileManager {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If no configDir is defined, fall back to default
|
||||
if (!profile.configDir) {
|
||||
// Fallback: retrieve OAuth token directly from Keychain when configDir is missing.
|
||||
// Without configDir, Claude CLI cannot resolve credentials automatically,
|
||||
// so we inject CLAUDE_CODE_OAUTH_TOKEN as a direct override.
|
||||
// This mirrors the fallback in getActiveProfileEnv().
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] getProfileEnv: profile has no configDir:',
|
||||
profile.name,
|
||||
'- falling back to Keychain token lookup.'
|
||||
);
|
||||
|
||||
const credentials = getCredentialsFromKeychain(undefined, true);
|
||||
if (credentials.token) {
|
||||
debugLog('[ClaudeProfileManager] getProfileEnv: injected CLAUDE_CODE_OAUTH_TOKEN from Keychain for profile:', profile.name);
|
||||
return { CLAUDE_CODE_OAUTH_TOKEN: credentials.token };
|
||||
}
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] getProfileEnv: no token found in Keychain for profile without configDir:',
|
||||
profile.name
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1825,6 +1825,7 @@ function updateLinuxFileCredentials(
|
||||
}
|
||||
|
||||
// Write to file with secure permissions (0600)
|
||||
// lgtm[js/http-to-file-access] - credentialsPath is from controlled configDir
|
||||
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
|
||||
|
||||
if (isDebug) {
|
||||
@@ -2086,6 +2087,7 @@ function updateWindowsFileCredentials(
|
||||
const tempPath = `${credentialsPath}.${Date.now()}.tmp`;
|
||||
try {
|
||||
// Write to temp file
|
||||
// lgtm[js/http-to-file-access] - credentialsPath is from controlled configDir
|
||||
writeFileSync(tempPath, credentialsJson, { encoding: 'utf-8' });
|
||||
|
||||
// Restrict temp file permissions to current user only (mimics Unix 0600)
|
||||
|
||||
@@ -330,6 +330,10 @@ function createWindow(): void {
|
||||
|
||||
// Clean up on close
|
||||
mainWindow.on('closed', () => {
|
||||
// Kill all agents when window closes (prevents orphaned processes)
|
||||
agentManager?.killAll?.()?.catch((err: unknown) => {
|
||||
console.warn('[main] Error killing agents on window close:', err);
|
||||
});
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ async function githubGraphQL<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
// lgtm[js/file-access-to-http] - Official GitHub GraphQL API endpoint
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -267,6 +268,10 @@ export interface PRReviewFinding {
|
||||
endLine?: number;
|
||||
suggestedFix?: string;
|
||||
fixable: boolean;
|
||||
validationStatus?: "confirmed_valid" | "dismissed_false_positive" | "needs_human_review" | null;
|
||||
validationExplanation?: string;
|
||||
sourceAgents?: string[];
|
||||
crossValidated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,7 +283,7 @@ export interface PRReviewResult {
|
||||
success: boolean;
|
||||
findings: PRReviewFinding[];
|
||||
summary: string;
|
||||
overallStatus: "approve" | "request_changes" | "comment";
|
||||
overallStatus: "approve" | "request_changes" | "comment" | "in_progress";
|
||||
reviewId?: number;
|
||||
reviewedAt: string;
|
||||
error?: string;
|
||||
@@ -294,6 +299,8 @@ export interface PRReviewResult {
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
// In-progress review tracking
|
||||
inProgressSince?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1338,6 +1345,10 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n
|
||||
endLine: f.end_line,
|
||||
suggestedFix: f.suggested_fix,
|
||||
fixable: f.fixable ?? false,
|
||||
validationStatus: f.validation_status ?? null,
|
||||
validationExplanation: f.validation_explanation ?? undefined,
|
||||
sourceAgents: f.source_agents ?? [],
|
||||
crossValidated: f.cross_validated ?? false,
|
||||
})) ?? [],
|
||||
summary: data.summary ?? "",
|
||||
overallStatus: data.overall_status ?? "comment",
|
||||
@@ -1356,6 +1367,8 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n
|
||||
hasPostedFindings: data.has_posted_findings ?? false,
|
||||
postedFindingIds: data.posted_finding_ids ?? [],
|
||||
postedAt: data.posted_at,
|
||||
// In-progress review tracking
|
||||
inProgressSince: data.in_progress_since,
|
||||
};
|
||||
} catch {
|
||||
// File doesn't exist or couldn't be read
|
||||
@@ -1514,7 +1527,32 @@ async function runPRReview(
|
||||
debugLog("Auth failure detected in PR review", authFailureInfo);
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
|
||||
},
|
||||
onComplete: () => {
|
||||
onComplete: (stdout: string) => {
|
||||
// Check stdout for in_progress JSON marker (not saved to disk by backend)
|
||||
const inProgressMarker = "__RESULT_JSON__:";
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (line.startsWith(inProgressMarker)) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(inProgressMarker.length));
|
||||
if (data.overall_status === "in_progress") {
|
||||
debugLog("In-progress result parsed from stdout", { prNumber });
|
||||
return {
|
||||
prNumber: data.pr_number,
|
||||
repo: data.repo,
|
||||
success: data.success,
|
||||
findings: [],
|
||||
summary: data.summary ?? "",
|
||||
overallStatus: "in_progress" as const,
|
||||
reviewedAt: data.reviewed_at ?? new Date().toISOString(),
|
||||
inProgressSince: data.in_progress_since,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
debugLog("Failed to parse __RESULT_JSON__ line", { line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the result from disk
|
||||
const reviewResult = getReviewResult(project, prNumber);
|
||||
if (!reviewResult) {
|
||||
@@ -1864,9 +1902,15 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
projectId
|
||||
);
|
||||
|
||||
// Check if already running
|
||||
// Check if already running — notify renderer so it can display ongoing logs
|
||||
if (runningReviews.has(reviewKey)) {
|
||||
debugLog("Review already running", { reviewKey });
|
||||
debugLog("Review already running, notifying renderer", { reviewKey });
|
||||
sendProgress({
|
||||
phase: "analyzing",
|
||||
prNumber,
|
||||
progress: 50,
|
||||
message: "Review is already in progress. Reconnecting to ongoing review...",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1936,6 +1980,20 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
const result = await runPRReview(project, prNumber, mainWindow);
|
||||
|
||||
if (result.overallStatus === "in_progress") {
|
||||
// Review is already running externally (detected by BotDetector).
|
||||
// Send the result as-is so the renderer can activate external review polling.
|
||||
debugLog("PR review already in progress externally", { prNumber });
|
||||
sendProgress({
|
||||
phase: "complete",
|
||||
prNumber,
|
||||
progress: 100,
|
||||
message: "Review already in progress",
|
||||
});
|
||||
sendComplete(result);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("PR review completed", { prNumber, findingsCount: result.findings.length });
|
||||
sendProgress({
|
||||
phase: "complete",
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function createSpecForIssue(
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, slugifiedTitle sanitizes input
|
||||
writeFileSync(
|
||||
path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN),
|
||||
JSON.stringify(implementationPlan, null, 2),
|
||||
@@ -148,6 +149,7 @@ export async function createSpecForIssue(
|
||||
task_description: safeDescription,
|
||||
workflow_type: 'feature'
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, slugifiedTitle sanitizes input
|
||||
writeFileSync(
|
||||
path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS),
|
||||
JSON.stringify(requirements, null, 2),
|
||||
@@ -167,6 +169,7 @@ export async function createSpecForIssue(
|
||||
// This comes from project.settings.mainBranch or task-level override
|
||||
...(baseBranch && { baseBranch })
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, slugifiedTitle sanitizes input
|
||||
writeFileSync(
|
||||
path.join(specDir, 'task_metadata.json'),
|
||||
JSON.stringify(metadata, null, 2),
|
||||
|
||||
@@ -8,8 +8,8 @@ import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { GitLabInvestigationStatus, GitLabInvestigationResult } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
|
||||
import type { GitLabAPIIssue, GitLabAPINote } from './types';
|
||||
import { buildIssueContext, createSpecForIssue } from './spec-utils';
|
||||
import type { GitLabAPIIssue, GitLabAPINoteBasic } from './types';
|
||||
import { createSpecForIssue, fetchAllIssueNotes } from './spec-utils';
|
||||
import type { AgentManager } from '../../agent';
|
||||
|
||||
// Debug logging helper
|
||||
@@ -109,51 +109,31 @@ export function registerInvestigateIssue(
|
||||
`/projects/${encodedProject}/issues/${issueIid}`
|
||||
) as GitLabAPIIssue;
|
||||
|
||||
// Fetch notes if any selected
|
||||
let selectedNotes: GitLabAPINote[] = [];
|
||||
// Fetch notes if any selected (with pagination to get all notes)
|
||||
let filteredNotes: GitLabAPINoteBasic[] = [];
|
||||
if (selectedNoteIds && selectedNoteIds.length > 0) {
|
||||
const allNotes = await gitlabFetch(
|
||||
config.token,
|
||||
config.instanceUrl,
|
||||
`/projects/${encodedProject}/issues/${issueIid}/notes`
|
||||
) as GitLabAPINote[];
|
||||
|
||||
selectedNotes = allNotes.filter(note => selectedNoteIds.includes(note.id));
|
||||
// Fetch all notes using the paginated utility function
|
||||
const allNotes = await fetchAllIssueNotes(config, encodedProject, issueIid);
|
||||
// Filter notes based on selection
|
||||
filteredNotes = allNotes.filter(note => selectedNoteIds.includes(note.id));
|
||||
}
|
||||
|
||||
// Phase 2: Analyzing
|
||||
sendProgress(getMainWindow, project.id, {
|
||||
phase: 'analyzing',
|
||||
issueIid,
|
||||
progress: 30,
|
||||
message: 'Analyzing issue with AI...'
|
||||
});
|
||||
|
||||
// Note: Context building previously done here has been moved to createSpecForIssue utility.
|
||||
// The buildIssueContext() function and selectedNotes processing are now handled internally
|
||||
// by the spec creation pipeline. This avoids duplicate context generation.
|
||||
// TODO: If advanced context customization is needed in the future, consider extracting
|
||||
// context building into a reusable utility function.
|
||||
|
||||
// Use agent manager to investigate
|
||||
// Note: This is a simplified version - full implementation would use Claude SDK
|
||||
sendProgress(getMainWindow, project.id, {
|
||||
phase: 'analyzing',
|
||||
issueIid,
|
||||
progress: 50,
|
||||
message: 'AI analyzing the issue...'
|
||||
});
|
||||
|
||||
// Phase 3: Creating task
|
||||
// Phase 2: Creating task
|
||||
sendProgress(getMainWindow, project.id, {
|
||||
phase: 'creating_task',
|
||||
issueIid,
|
||||
progress: 80,
|
||||
message: 'Creating task from analysis...'
|
||||
progress: 50,
|
||||
message: 'Creating task from issue...'
|
||||
});
|
||||
|
||||
// Create spec for the issue
|
||||
const task = await createSpecForIssue(project, issue, config, project.settings?.mainBranch);
|
||||
// Create spec for the issue with notes
|
||||
const task = await createSpecForIssue(
|
||||
project,
|
||||
issue,
|
||||
config,
|
||||
project.settings?.mainBranch,
|
||||
filteredNotes
|
||||
);
|
||||
|
||||
if (!task) {
|
||||
sendError(getMainWindow, project.id, 'Failed to create task from issue');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { mkdir, writeFile, readFile, stat } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { Project } from '../../../shared/types';
|
||||
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
||||
import type { GitLabAPIIssue, GitLabAPINoteBasic, GitLabConfig } from './types';
|
||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
||||
import { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
|
||||
@@ -208,7 +208,12 @@ function generateSpecDirName(issueIid: number, title: string): string {
|
||||
/**
|
||||
* Build issue context for spec creation
|
||||
*/
|
||||
export function buildIssueContext(issue: IssueLike, projectPath: string, instanceUrl: string): string {
|
||||
export function buildIssueContext(
|
||||
issue: IssueLike,
|
||||
projectPath: string,
|
||||
instanceUrl: string,
|
||||
notes?: GitLabAPINoteBasic[]
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const safeProjectPath = sanitizeText(projectPath, 200);
|
||||
const safeIssue = sanitizeIssueForSpec(issue, instanceUrl);
|
||||
@@ -238,6 +243,19 @@ export function buildIssueContext(issue: IssueLike, projectPath: string, instanc
|
||||
lines.push('');
|
||||
lines.push(`**Web URL:** ${safeIssue.web_url}`);
|
||||
|
||||
// Add notes section if notes are provided
|
||||
if (notes && notes.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`## Notes (${notes.length})`);
|
||||
lines.push('');
|
||||
for (const note of notes) {
|
||||
const safeAuthor = sanitizeText(note.author?.username || 'unknown', 100);
|
||||
const safeBody = sanitizeText(note.body, 20000, true);
|
||||
lines.push(`**${safeAuthor}:** ${safeBody}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -253,6 +271,103 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all notes for a GitLab issue with pagination.
|
||||
* Handles rate limiting and authentication errors gracefully.
|
||||
*
|
||||
* @param config GitLab configuration with token and instance URL
|
||||
* @param encodedProject URL-encoded project path
|
||||
* @param issueIid Issue IID to fetch notes for
|
||||
* @returns Array of basic note objects with id, body, and author
|
||||
*/
|
||||
export async function fetchAllIssueNotes(
|
||||
config: { token: string; instanceUrl: string },
|
||||
encodedProject: string,
|
||||
issueIid: number
|
||||
): Promise<GitLabAPINoteBasic[]> {
|
||||
const { gitlabFetch } = await import('./utils');
|
||||
const { GitLabAPIError } = await import('./utils');
|
||||
|
||||
const allNotes: GitLabAPINoteBasic[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
const MAX_PAGES = 50; // Safety limit: max 5000 notes
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && page <= MAX_PAGES) {
|
||||
try {
|
||||
const notesPage = await gitlabFetch(
|
||||
config.token,
|
||||
config.instanceUrl,
|
||||
`/projects/${encodedProject}/issues/${issueIid}/notes?page=${page}&per_page=${perPage}`
|
||||
) as unknown[];
|
||||
|
||||
// Runtime validation: ensure we got an array
|
||||
if (!Array.isArray(notesPage)) {
|
||||
debugLog('GitLab notes API returned non-array, stopping pagination');
|
||||
break;
|
||||
}
|
||||
|
||||
if (notesPage.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// Extract only needed fields with null-safe defaults
|
||||
const noteSummaries: GitLabAPINoteBasic[] = notesPage
|
||||
.filter((note: unknown): note is Record<string, unknown> =>
|
||||
note !== null && typeof note === 'object' && typeof (note as Record<string, unknown>).id === 'number'
|
||||
)
|
||||
.map((note) => {
|
||||
// Validate author structure defensively
|
||||
const author = note.author;
|
||||
const username = (author !== null && typeof author === 'object' && typeof (author as Record<string, unknown>).username === 'string')
|
||||
? (author as Record<string, unknown>).username as string
|
||||
: 'unknown';
|
||||
return {
|
||||
id: note.id as number,
|
||||
body: (note.body as string | undefined) || '',
|
||||
author: { username },
|
||||
};
|
||||
});
|
||||
allNotes.push(...noteSummaries);
|
||||
if (notesPage.length < perPage) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check for authentication/rate-limit errors using structured status codes
|
||||
const isAuthError = error instanceof GitLabAPIError && (error.statusCode === 401 || error.statusCode === 403);
|
||||
const isRateLimited = error instanceof GitLabAPIError && error.statusCode === 429;
|
||||
|
||||
if (isAuthError || isRateLimited) {
|
||||
// Re-throw critical errors to let the caller surface them to the user
|
||||
const statusCode = error instanceof GitLabAPIError ? error.statusCode : undefined;
|
||||
console.warn(`[GitLab Notes] ${isAuthError ? 'Authentication' : 'Rate limit'} error during notes fetch`, { page, error: errorMessage, statusCode });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For transient errors on page 1, warn the user but continue
|
||||
if (page === 1 && allNotes.length === 0) {
|
||||
console.warn('[GitLab Notes] Failed to fetch any notes, proceeding without notes context', { error: errorMessage });
|
||||
} else {
|
||||
// Log pagination failure for subsequent pages
|
||||
debugLog('Failed to fetch notes page, using partial notes', { page, error: errorMessage, notesRetrieved: allNotes.length });
|
||||
}
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if we hit the pagination limit
|
||||
if (page > MAX_PAGES && hasMore) {
|
||||
debugLog('Pagination limit reached, some notes may be missing', { maxPages: MAX_PAGES, notesRetrieved: allNotes.length });
|
||||
}
|
||||
|
||||
return allNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task spec from a GitLab issue
|
||||
*/
|
||||
@@ -260,7 +375,8 @@ export async function createSpecForIssue(
|
||||
project: Project,
|
||||
issue: GitLabAPIIssue,
|
||||
config: GitLabConfig,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
notes?: GitLabAPINoteBasic[]
|
||||
): Promise<GitLabTaskInfo | null> {
|
||||
try {
|
||||
// Validate and sanitize network data before writing to disk
|
||||
@@ -319,8 +435,8 @@ export async function createSpecForIssue(
|
||||
// Create spec directory
|
||||
await mkdir(specDir, { recursive: true });
|
||||
|
||||
// Create TASK.md with issue context
|
||||
const taskContent = buildIssueContext(safeIssue, safeProject, config.instanceUrl);
|
||||
// Create TASK.md with issue context (including selected notes)
|
||||
const taskContent = buildIssueContext(safeIssue, safeProject, safeInstanceUrl, notes);
|
||||
await writeFile(path.join(specDir, 'TASK.md'), taskContent, 'utf-8');
|
||||
|
||||
// Create metadata.json (legacy format for GitLab-specific data)
|
||||
|
||||
@@ -420,6 +420,7 @@ export function registerTriageHandlers(
|
||||
}
|
||||
|
||||
// Save result
|
||||
// lgtm[js/http-to-file-access] - triageDir from controlled project path, issue_iid is numeric
|
||||
fs.writeFileSync(
|
||||
path.join(triageDir, `triage_${sanitizedResult.issue_iid}.json`),
|
||||
JSON.stringify(sanitizedResult, null, 2),
|
||||
|
||||
@@ -51,6 +51,13 @@ export interface GitLabAPINote {
|
||||
system: boolean;
|
||||
}
|
||||
|
||||
// Basic note type with only fields needed by investigation handlers
|
||||
export interface GitLabAPINoteBasic {
|
||||
id: number;
|
||||
body: string;
|
||||
author: { username: string };
|
||||
}
|
||||
|
||||
export interface GitLabAPIMergeRequest {
|
||||
id: number;
|
||||
iid: number;
|
||||
|
||||
@@ -13,6 +13,19 @@ import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
|
||||
const DEFAULT_GITLAB_URL = 'https://gitlab.com';
|
||||
|
||||
/**
|
||||
* Custom error class for GitLab API errors with structured status code
|
||||
*/
|
||||
export class GitLabAPIError extends Error {
|
||||
public readonly statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = 'GitLabAPIError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function parseInstanceUrl(value: string): string | null {
|
||||
const candidate = value.trim();
|
||||
if (!candidate) return null;
|
||||
@@ -261,13 +274,16 @@ export async function gitlabFetch(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`GitLab API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
||||
throw new GitLabAPIError(
|
||||
`GitLab API error: ${response.status} ${response.statusText} - ${errorBody}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`GitLab API timeout after ${GITLAB_API_TIMEOUT_MS / 1000}s: ${url}`);
|
||||
throw new GitLabAPIError(`GitLab API timeout after ${GITLAB_API_TIMEOUT_MS / 1000}s: ${url}`, 0);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -316,7 +332,10 @@ export async function gitlabFetchWithCount(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`GitLab API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
||||
throw new GitLabAPIError(
|
||||
`GitLab API error: ${response.status} ${response.statusText} - ${errorBody}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
// Get total count from X-Total header (GitLab's pagination header)
|
||||
@@ -327,7 +346,7 @@ export async function gitlabFetchWithCount(
|
||||
return { data, totalCount };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`GitLab API timeout after ${GITLAB_API_TIMEOUT_MS / 1000}s: ${url}`);
|
||||
throw new GitLabAPIError(`GitLab API timeout after ${GITLAB_API_TIMEOUT_MS / 1000}s: ${url}`, 0);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { registerProfileHandlers } from './profile-handlers';
|
||||
import { registerScreenshotHandlers } from './screenshot-handlers';
|
||||
import { registerTerminalWorktreeIpcHandlers } from './terminal';
|
||||
import { notificationService } from '../notification-service';
|
||||
import { setAgentManagerRef } from './utils';
|
||||
|
||||
/**
|
||||
* Setup all IPC handlers across all domains
|
||||
@@ -53,6 +54,9 @@ export function setupIpcHandlers(
|
||||
// Initialize notification service
|
||||
notificationService.initialize(getMainWindow);
|
||||
|
||||
// Wire up agent manager for circuit breaker cleanup
|
||||
setAgentManagerRef(agentManager);
|
||||
|
||||
// Project handlers (including Python environment setup)
|
||||
registerProjectHandlers(pythonEnvManager, agentManager, getMainWindow);
|
||||
|
||||
|
||||
@@ -507,6 +507,7 @@ ${safeDescription || 'No description provided.'}
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2), 'utf-8');
|
||||
|
||||
// Create requirements.json
|
||||
@@ -514,6 +515,7 @@ ${safeDescription || 'No description provided.'}
|
||||
task_description: description,
|
||||
workflow_type: 'feature'
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2), 'utf-8');
|
||||
|
||||
// Build metadata
|
||||
@@ -524,6 +526,7 @@ ${safeDescription || 'No description provided.'}
|
||||
linearUrl: safeUrl,
|
||||
category: 'feature'
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { existsSync, } from 'fs';
|
||||
import path from 'path';
|
||||
import { ipcMain } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
OtherWorktreeInfo,
|
||||
} from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync } from 'fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync, copyFileSync, cpSync, statSync, readlinkSync } from 'fs';
|
||||
import { execFileSync, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { minimatch } from 'minimatch';
|
||||
@@ -57,6 +57,21 @@ function isTimeoutError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a symlink or Windows junction (including broken ones).
|
||||
* Uses readlinkSync which works for both symlinks and junctions on all platforms.
|
||||
*/
|
||||
function isSymlinkOrJunction(targetPath: string): boolean {
|
||||
try {
|
||||
// readlinkSync throws if the path is not a symlink/junction
|
||||
// It works for both symlinks and junctions on Windows and Unix
|
||||
readlinkSync(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // Path doesn't exist or is not a symlink/junction
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix repositories that are incorrectly marked with core.bare=true.
|
||||
* This can happen when git worktree operations incorrectly set bare=true
|
||||
@@ -226,87 +241,453 @@ function getDefaultBranch(projectPath: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink node_modules from project root to worktree for TypeScript and tooling support.
|
||||
* This allows pre-commit hooks and IDE features to work without npm install in the worktree.
|
||||
* Configuration for a single dependency to be shared in a worktree.
|
||||
*/
|
||||
interface DependencyConfig {
|
||||
/** Dependency type identifier (e.g., 'node_modules', 'venv') */
|
||||
depType: string;
|
||||
/** Strategy for sharing this dependency in worktrees */
|
||||
strategy: 'symlink' | 'recreate' | 'copy' | 'skip';
|
||||
/** Relative path from project root to the dependency directory */
|
||||
sourceRelPath: string;
|
||||
/** Path to requirements file for recreate strategy (e.g., 'requirements.txt') */
|
||||
requirementsFile?: string;
|
||||
/** Package manager used (e.g., 'npm', 'pip', 'uv') */
|
||||
packageManager?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mapping from dependency type to sharing strategy.
|
||||
*
|
||||
* Data-driven — add new entries here rather than writing if/else branches.
|
||||
* Mirrors the Python implementation in apps/backend/core/workspace/dependency_strategy.py.
|
||||
*/
|
||||
const DEFAULT_STRATEGY_MAP: Record<string, 'symlink' | 'recreate' | 'copy' | 'skip'> = {
|
||||
// JavaScript / Node.js — symlink is safe and fast
|
||||
node_modules: 'symlink',
|
||||
// Python — symlink for fast worktree creation.
|
||||
// CPython bug #106045 (pyvenv.cfg symlink resolution) does not affect
|
||||
// typical usage (running scripts, imports, pip). If the health check
|
||||
// after symlinking fails, we fall back to recreate automatically.
|
||||
venv: 'symlink',
|
||||
'.venv': 'symlink',
|
||||
// PHP — Composer vendor dir is safe to symlink
|
||||
vendor_php: 'symlink',
|
||||
// Ruby — Bundler vendor/bundle is safe to symlink
|
||||
vendor_bundle: 'symlink',
|
||||
// Rust — build output dir, skip (rebuilt per-worktree)
|
||||
cargo_target: 'skip',
|
||||
// Go — global module cache, nothing in-tree to share
|
||||
go_modules: 'skip',
|
||||
};
|
||||
|
||||
/**
|
||||
* Load dependency configs from the project index, or fall back to hardcoded
|
||||
* node_modules-only behavior for backward compatibility.
|
||||
*/
|
||||
function loadDependencyConfigs(projectPath: string): DependencyConfig[] {
|
||||
const indexPath = path.join(projectPath, '.auto-claude', 'project_index.json');
|
||||
|
||||
if (existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
||||
// Use the aggregated top-level dependency_locations which already
|
||||
// contain project-relative paths (e.g. "apps/backend/.venv" instead
|
||||
// of just ".venv"), avoiding a monorepo path resolution bug.
|
||||
const depLocations = index?.dependency_locations;
|
||||
if (Array.isArray(depLocations)) {
|
||||
const configs: DependencyConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const dep of depLocations) {
|
||||
if (!dep || typeof dep !== 'object') continue;
|
||||
const depObj = dep as Record<string, unknown>;
|
||||
const depType = String(depObj.type || '');
|
||||
const relPath = String(depObj.path || '');
|
||||
if (!depType || !relPath || seen.has(relPath)) continue;
|
||||
|
||||
// Path containment: reject absolute paths and traversals
|
||||
if (path.isAbsolute(relPath)) continue;
|
||||
if (relPath.split('/').includes('..') || relPath.split('\\').includes('..')) continue;
|
||||
|
||||
// Defense-in-depth: verify resolved path stays within project
|
||||
const resolved = path.resolve(projectPath, relPath);
|
||||
if (!resolved.startsWith(path.resolve(projectPath) + path.sep)) continue;
|
||||
|
||||
seen.add(relPath);
|
||||
|
||||
const strategy = DEFAULT_STRATEGY_MAP[depType] ?? 'skip';
|
||||
|
||||
// Validate requirementsFile path containment
|
||||
let reqFile: string | undefined;
|
||||
if (depObj.requirements_file) {
|
||||
const rf = String(depObj.requirements_file);
|
||||
const rfParts = rf.split('/');
|
||||
const rfPartsWin = rf.split('\\');
|
||||
if (!path.isAbsolute(rf) && !rfParts.includes('..') && !rfPartsWin.includes('..')) {
|
||||
// Defense-in-depth: resolved-path containment (matches relPath check)
|
||||
const resolvedReq = path.resolve(projectPath, rf);
|
||||
if (resolvedReq.startsWith(path.resolve(projectPath) + path.sep)) {
|
||||
reqFile = rf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configs.push({
|
||||
depType,
|
||||
strategy,
|
||||
sourceRelPath: relPath,
|
||||
requirementsFile: reqFile,
|
||||
packageManager: depObj.package_manager ? String(depObj.package_manager) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (configs.length > 0) {
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Failed to read project index:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hardcoded node_modules-only behavior (same as legacy)
|
||||
return [
|
||||
{ depType: 'node_modules', strategy: 'symlink', sourceRelPath: 'node_modules' },
|
||||
{ depType: 'node_modules', strategy: 'symlink', sourceRelPath: 'apps/frontend/node_modules' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dependencies in a worktree using strategy-based dispatch.
|
||||
*
|
||||
* Reads dependency configs from the project index and applies the correct
|
||||
* strategy for each: symlink, recreate, copy, or skip.
|
||||
*
|
||||
* All operations are non-blocking on failure — errors are logged but never thrown.
|
||||
*
|
||||
* @param projectPath - The main project directory
|
||||
* @param worktreePath - Path to the worktree
|
||||
* @returns Array of symlinked paths (relative to worktree)
|
||||
* @returns Array of successfully processed dependency relative paths
|
||||
*/
|
||||
function symlinkNodeModulesToWorktree(projectPath: string, worktreePath: string): string[] {
|
||||
async function setupWorktreeDependencies(projectPath: string, worktreePath: string): Promise<string[]> {
|
||||
const configs = loadDependencyConfigs(projectPath);
|
||||
const processed: string[] = [];
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
let performed = false;
|
||||
switch (config.strategy) {
|
||||
case 'symlink':
|
||||
performed = applySymlinkStrategy(projectPath, worktreePath, config);
|
||||
// For venvs, verify the symlink is usable — fall back to recreate if not
|
||||
// Run health check whenever a venv exists (not just on fresh creation)
|
||||
if (config.depType === 'venv' || config.depType === '.venv') {
|
||||
const venvPath = path.join(worktreePath, config.sourceRelPath);
|
||||
// Check if venv path exists (as symlink or otherwise)
|
||||
if (existsSync(venvPath) || isSymlinkOrJunction(venvPath)) {
|
||||
const pythonBin = isWindows()
|
||||
? path.join(venvPath, 'Scripts', 'python.exe')
|
||||
: path.join(venvPath, 'bin', 'python');
|
||||
try {
|
||||
await execFileAsync(pythonBin, ['-c', 'import sys; print(sys.prefix)'], {
|
||||
timeout: 10000,
|
||||
});
|
||||
debugLog('[TerminalWorktree] Symlinked venv health check passed:', config.sourceRelPath);
|
||||
} catch {
|
||||
debugLog('[TerminalWorktree] Symlinked venv health check failed, falling back to recreate:', config.sourceRelPath);
|
||||
debugLog('[TerminalWorktree] Venv fallback: removing broken symlink and recreating for', config.sourceRelPath);
|
||||
// Remove the broken symlink and recreate
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
performed = await applyRecreateStrategy(projectPath, worktreePath, config);
|
||||
if (performed) {
|
||||
debugLog('[TerminalWorktree] Venv fallback to recreate succeeded:', config.sourceRelPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'recreate':
|
||||
performed = await applyRecreateStrategy(projectPath, worktreePath, config);
|
||||
break;
|
||||
case 'copy':
|
||||
performed = applyCopyStrategy(projectPath, worktreePath, config);
|
||||
break;
|
||||
case 'skip':
|
||||
debugLog('[TerminalWorktree] Skipping', config.depType, `(${config.sourceRelPath}) - skip strategy`);
|
||||
continue; // Don't record skipped entries in processed list
|
||||
}
|
||||
if (performed) processed.push(config.sourceRelPath);
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Failed to apply', config.strategy, 'strategy for', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Failed to set up ${config.sourceRelPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply symlink strategy: create a symlink (or Windows junction) from worktree to project source.
|
||||
* Reuses the existing platform-specific symlink creation pattern.
|
||||
*/
|
||||
function applySymlinkStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): boolean {
|
||||
const sourcePath = path.join(projectPath, config.sourceRelPath);
|
||||
const targetPath = path.join(worktreePath, config.sourceRelPath);
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- source missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for broken symlinks and remove them so a fresh symlink can be created
|
||||
if (isSymlinkOrJunction(targetPath)) {
|
||||
if (!existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Removing broken symlink for', config.sourceRelPath);
|
||||
try { rmSync(targetPath, { force: true }); } catch { /* best-effort */ }
|
||||
} else {
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists (symlink)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (isWindows()) {
|
||||
symlinkSync(sourcePath, targetPath, 'junction');
|
||||
debugLog('[TerminalWorktree] Created junction (Windows):', config.sourceRelPath, '->', sourcePath);
|
||||
} else {
|
||||
const relativePath = path.relative(path.dirname(targetPath), sourcePath);
|
||||
symlinkSync(relativePath, targetPath);
|
||||
debugLog('[TerminalWorktree] Created symlink (Unix):', config.sourceRelPath, '->', relativePath);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Could not create symlink for', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Failed to link ${config.sourceRelPath}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marker file written inside a recreated venv to indicate setup completed successfully. */
|
||||
const VENV_SETUP_COMPLETE_MARKER = '.setup_complete';
|
||||
|
||||
/**
|
||||
* Apply recreate strategy: create a fresh virtual environment in the worktree.
|
||||
*
|
||||
* Used as a fallback when venv symlinking fails (CPython bug #106045).
|
||||
* Writes a completion marker so incomplete venvs can be detected and rebuilt.
|
||||
*/
|
||||
async function applyRecreateStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): Promise<boolean> {
|
||||
const venvPath = path.join(worktreePath, config.sourceRelPath);
|
||||
const markerPath = path.join(venvPath, VENV_SETUP_COMPLETE_MARKER);
|
||||
|
||||
// Check for broken symlinks that existsSync would miss
|
||||
if (isSymlinkOrJunction(venvPath) && !existsSync(venvPath)) {
|
||||
debugLog('[TerminalWorktree] Removing broken symlink at', config.sourceRelPath);
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
} else if (existsSync(venvPath)) {
|
||||
if (existsSync(markerPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping recreate', config.sourceRelPath, '- already complete (marker present)');
|
||||
return false;
|
||||
}
|
||||
// Venv exists but marker is missing — incomplete, remove and rebuild
|
||||
debugLog('[TerminalWorktree] Removing incomplete venv', config.sourceRelPath, '(no marker)');
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Detect Python executable from the source venv or fall back to system Python
|
||||
const sourceVenv = path.join(projectPath, config.sourceRelPath);
|
||||
let pythonExec = isWindows() ? 'python' : 'python3';
|
||||
|
||||
if (existsSync(sourceVenv)) {
|
||||
const unixCandidate = path.join(sourceVenv, 'bin', 'python');
|
||||
const winCandidate = path.join(sourceVenv, 'Scripts', 'python.exe');
|
||||
if (existsSync(unixCandidate)) {
|
||||
pythonExec = unixCandidate;
|
||||
} else if (existsSync(winCandidate)) {
|
||||
pythonExec = winCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the venv
|
||||
try {
|
||||
debugLog('[TerminalWorktree] Creating venv at', config.sourceRelPath);
|
||||
await execFileAsync(pythonExec, ['-m', 'venv', venvPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
debugError('[TerminalWorktree] venv creation timed out for', config.sourceRelPath);
|
||||
console.warn(`[TerminalWorktree] Warning: venv creation timed out for ${config.sourceRelPath}`);
|
||||
} else {
|
||||
debugError('[TerminalWorktree] venv creation failed for', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Could not create venv at ${config.sourceRelPath}`);
|
||||
}
|
||||
// Clean up partial venv so retries aren't blocked
|
||||
if (existsSync(venvPath)) {
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Install from requirements file if specified
|
||||
if (config.requirementsFile) {
|
||||
const reqPath = path.join(projectPath, config.requirementsFile);
|
||||
if (existsSync(reqPath)) {
|
||||
const pipExec = isWindows()
|
||||
? path.join(venvPath, 'Scripts', 'pip.exe')
|
||||
: path.join(venvPath, 'bin', 'pip');
|
||||
|
||||
// Build install command based on file type
|
||||
const reqBasename = path.basename(config.requirementsFile);
|
||||
let installArgs: string[] | null;
|
||||
if (reqBasename === 'pyproject.toml') {
|
||||
// Snapshot-install from worktree copy (non-editable to avoid
|
||||
// symlinking back to the main project source tree).
|
||||
const worktreeReq = path.join(worktreePath, config.requirementsFile!);
|
||||
const installDir = existsSync(worktreeReq) ? path.dirname(worktreeReq) : path.dirname(reqPath);
|
||||
installArgs = ['install', installDir];
|
||||
} else if (reqBasename === 'Pipfile') {
|
||||
debugLog('[TerminalWorktree] Skipping Pipfile-based install (use pipenv in worktree)');
|
||||
installArgs = null;
|
||||
} else {
|
||||
installArgs = ['install', '-r', reqPath];
|
||||
}
|
||||
|
||||
if (installArgs) {
|
||||
try {
|
||||
debugLog('[TerminalWorktree] Installing deps from', config.requirementsFile);
|
||||
await execFileAsync(pipExec, installArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 300000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
debugError('[TerminalWorktree] pip install timed out for', config.requirementsFile);
|
||||
console.warn(`[TerminalWorktree] Warning: Dependency install timed out for ${config.requirementsFile}`);
|
||||
} else {
|
||||
debugError('[TerminalWorktree] pip install failed:', error);
|
||||
}
|
||||
// Clean up broken venv so retries aren't blocked
|
||||
if (existsSync(venvPath)) {
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write completion marker so future runs know this venv is complete
|
||||
try {
|
||||
writeFileSync(markerPath, '');
|
||||
} catch (error) {
|
||||
debugLog('[TerminalWorktree] Failed to write completion marker at', markerPath, ':', error);
|
||||
}
|
||||
|
||||
debugLog('[TerminalWorktree] Recreated venv at', config.sourceRelPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply copy strategy: copy a file or directory from project to worktree.
|
||||
*/
|
||||
function applyCopyStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): boolean {
|
||||
const sourcePath = path.join(projectPath, config.sourceRelPath);
|
||||
const targetPath = path.join(worktreePath, config.sourceRelPath);
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping copy', config.sourceRelPath, '- source missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping copy', config.sourceRelPath, '- target exists');
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (statSync(sourcePath).isDirectory()) {
|
||||
cpSync(sourcePath, targetPath, { recursive: true });
|
||||
} else {
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
debugLog('[TerminalWorktree] Copied', config.sourceRelPath, 'to worktree');
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Could not copy', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Could not copy ${config.sourceRelPath}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink the project root's .claude/ directory into a terminal worktree.
|
||||
* This enables Claude Code features (settings, commands, memory) in worktree terminals.
|
||||
* Follows the same pattern as setupWorktreeDependencies().
|
||||
*/
|
||||
function symlinkClaudeConfigToWorktree(projectPath: string, worktreePath: string): string[] {
|
||||
const symlinked: string[] = [];
|
||||
|
||||
// Node modules locations to symlink for TypeScript and tooling support.
|
||||
// These are the standard locations for this monorepo structure.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Hardcoded paths are intentional for simplicity and reliability
|
||||
// - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
// and potential failure points without significant benefit
|
||||
// - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
// in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
//
|
||||
// To add new workspace locations:
|
||||
// 1. Add [sourceRelPath, targetRelPath] tuple below
|
||||
// 2. Update the parallel Python implementation in apps/backend/core/workspace/setup.py
|
||||
// 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
const nodeModulesLocations = [
|
||||
['node_modules', 'node_modules'],
|
||||
['apps/frontend/node_modules', 'apps/frontend/node_modules'],
|
||||
];
|
||||
const sourceRel = '.claude';
|
||||
const sourcePath = path.join(projectPath, sourceRel);
|
||||
const targetPath = path.join(worktreePath, sourceRel);
|
||||
|
||||
for (const [sourceRel, targetRel] of nodeModulesLocations) {
|
||||
const sourcePath = path.join(projectPath, sourceRel);
|
||||
const targetPath = path.join(worktreePath, targetRel);
|
||||
// Skip if source doesn't exist
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping .claude symlink - source does not exist:', sourcePath);
|
||||
return symlinked;
|
||||
}
|
||||
|
||||
// Skip if source doesn't exist
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink - source does not exist:', sourceRel);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if target already exists (don't overwrite existing node_modules)
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink - target already exists:', targetRel);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also skip if target is a symlink (even if broken)
|
||||
try {
|
||||
lstatSync(targetPath);
|
||||
debugLog('[TerminalWorktree] Skipping symlink - target exists (possibly broken symlink):', targetRel);
|
||||
continue;
|
||||
} catch {
|
||||
// Target doesn't exist at all - good, we can create symlink
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// Platform-specific symlink creation:
|
||||
// - Windows: Use 'junction' type which requires absolute paths (no admin rights required)
|
||||
// - Unix (macOS/Linux): Use relative paths for portability (worktree can be moved)
|
||||
if (isWindows()) {
|
||||
symlinkSync(sourcePath, targetPath, 'junction');
|
||||
debugLog('[TerminalWorktree] Created junction (Windows):', targetRel, '->', sourcePath);
|
||||
} else {
|
||||
// On Unix, use relative symlinks for portability (matches Python implementation)
|
||||
const relativePath = path.relative(path.dirname(targetPath), sourcePath);
|
||||
symlinkSync(relativePath, targetPath);
|
||||
debugLog('[TerminalWorktree] Created symlink (Unix):', targetRel, '->', relativePath);
|
||||
}
|
||||
symlinked.push(targetRel);
|
||||
} catch (error) {
|
||||
// Symlink creation can fail on some systems (e.g., FAT32 filesystem, or permission issues)
|
||||
// Log warning but don't fail - worktree is still usable, just without TypeScript checking
|
||||
// Note: This warning appears in dev console. Users may see TypeScript errors in pre-commit hooks.
|
||||
debugError('[TerminalWorktree] Could not create symlink for', targetRel, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Failed to link ${targetRel} - TypeScript checks may fail in this worktree`);
|
||||
// Skip if target already exists
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping .claude symlink - target already exists:', targetPath);
|
||||
return symlinked;
|
||||
}
|
||||
|
||||
// Also skip if target is a symlink (even if broken)
|
||||
try {
|
||||
lstatSync(targetPath);
|
||||
debugLog('[TerminalWorktree] Skipping .claude symlink - target exists (possibly broken symlink):', targetPath);
|
||||
return symlinked;
|
||||
} catch {
|
||||
// Target doesn't exist at all - good, we can create symlink
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (isWindows()) {
|
||||
symlinkSync(sourcePath, targetPath, 'junction');
|
||||
debugLog('[TerminalWorktree] Created .claude junction (Windows):', sourceRel, '->', sourcePath);
|
||||
} else {
|
||||
const relativePath = path.relative(path.dirname(targetPath), sourcePath);
|
||||
symlinkSync(relativePath, targetPath);
|
||||
debugLog('[TerminalWorktree] Created .claude symlink (Unix):', sourceRel, '->', relativePath);
|
||||
}
|
||||
symlinked.push(sourceRel);
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Could not create symlink for .claude:', error);
|
||||
}
|
||||
|
||||
return symlinked;
|
||||
@@ -516,11 +897,17 @@ async function createTerminalWorktree(
|
||||
debugLog('[TerminalWorktree] Created worktree in detached HEAD mode from', baseRef);
|
||||
}
|
||||
|
||||
// Symlink node_modules for TypeScript and tooling support
|
||||
// Set up dependencies (node_modules, venvs, etc.) for tooling support
|
||||
// This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
const symlinkedModules = symlinkNodeModulesToWorktree(projectPath, worktreePath);
|
||||
if (symlinkedModules.length > 0) {
|
||||
debugLog('[TerminalWorktree] Symlinked dependencies:', symlinkedModules.join(', '));
|
||||
const setupDeps = await setupWorktreeDependencies(projectPath, worktreePath);
|
||||
if (setupDeps.length > 0) {
|
||||
debugLog('[TerminalWorktree] Set up worktree dependencies:', setupDeps.join(', '));
|
||||
}
|
||||
|
||||
// Symlink .claude/ config for Claude Code features (settings, commands, memory)
|
||||
const symlinkedClaude = symlinkClaudeConfigToWorktree(projectPath, worktreePath);
|
||||
if (symlinkedClaude.length > 0) {
|
||||
debugLog('[TerminalWorktree] Symlinked Claude config:', symlinkedClaude.join(', '));
|
||||
}
|
||||
|
||||
const config: TerminalWorktreeConfig = {
|
||||
|
||||
@@ -12,6 +12,17 @@ import type { BrowserWindow } from "electron";
|
||||
const warnTimestamps = new Map<string, number>();
|
||||
const WARN_COOLDOWN_MS = 5000; // 5 seconds between warnings per channel
|
||||
|
||||
/** Circuit breaker: kill agents after consecutive renderer disposal errors */
|
||||
const MAX_CONSECUTIVE_DISPOSAL_ERRORS = 10;
|
||||
let consecutiveDisposalErrors = 0;
|
||||
let agentManagerRef: { killAll: () => void | Promise<void> } | null = null;
|
||||
let circuitBreakerTriggered = false;
|
||||
|
||||
/** Set agent manager reference for circuit breaker cleanup */
|
||||
export function setAgentManagerRef(manager: { killAll: () => void | Promise<void> }): void {
|
||||
agentManagerRef = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel is within the warning cooldown period.
|
||||
* @returns true if within cooldown (should skip warning), false if cooldown expired
|
||||
@@ -108,6 +119,9 @@ export function safeSendToRenderer(
|
||||
|
||||
// All checks passed - safe to send
|
||||
mainWindow.webContents.send(channel, ...args);
|
||||
// On successful send, reset circuit breaker state (allow re-trigger after recovery)
|
||||
consecutiveDisposalErrors = 0;
|
||||
circuitBreakerTriggered = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Catch any disposal errors that might occur between our checks and the actual send
|
||||
@@ -115,6 +129,16 @@ export function safeSendToRenderer(
|
||||
|
||||
// Only log disposal errors once per channel to avoid log spam
|
||||
if (errorMessage.includes("disposed") || errorMessage.includes("destroyed")) {
|
||||
// Circuit breaker: track consecutive disposal errors
|
||||
consecutiveDisposalErrors++;
|
||||
if (consecutiveDisposalErrors >= MAX_CONSECUTIVE_DISPOSAL_ERRORS && !circuitBreakerTriggered && agentManagerRef) {
|
||||
circuitBreakerTriggered = true;
|
||||
console.error('[safeSendToRenderer] Circuit breaker triggered: killing all agents after renderer death');
|
||||
Promise.resolve(agentManagerRef.killAll()).catch((err) => {
|
||||
console.error('[safeSendToRenderer] Error killing agents:', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (!isWithinCooldown(channel)) {
|
||||
console.warn(`[safeSendToRenderer] Frame disposed, skipping send: ${channel}`);
|
||||
recordWarning(channel);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { getClaudeProfileManager } from './claude-profile-manager';
|
||||
import { getUsageMonitor } from './claude-profile/usage-monitor';
|
||||
import { debugLog } from '../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Regex pattern to detect Claude Code rate limit messages
|
||||
@@ -476,6 +477,14 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
debugLog('[RateLimitDetector] getBestAvailableProfileEnv() called:', {
|
||||
activeProfileId: activeProfile.id,
|
||||
activeProfileName: activeProfile.name,
|
||||
hasConfigDir: !!activeProfile.configDir,
|
||||
configDir: activeProfile.configDir,
|
||||
weeklyUsagePercent: activeProfile.usage?.weeklyUsagePercent,
|
||||
});
|
||||
|
||||
// Check for explicit rate limit (from previous API errors)
|
||||
const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id);
|
||||
|
||||
@@ -492,28 +501,24 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
: undefined;
|
||||
|
||||
if (needsSwap) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] Active profile needs swap:', {
|
||||
activeProfile: activeProfile.name,
|
||||
isRateLimited: rateLimitStatus.limited,
|
||||
isAtCapacity,
|
||||
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
|
||||
limitType: rateLimitStatus.type,
|
||||
resetAt: rateLimitStatus.resetAt
|
||||
});
|
||||
}
|
||||
debugLog('[RateLimitDetector] Active profile needs swap:', {
|
||||
activeProfile: activeProfile.name,
|
||||
isRateLimited: rateLimitStatus.limited,
|
||||
isAtCapacity,
|
||||
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
|
||||
limitType: rateLimitStatus.type,
|
||||
resetAt: rateLimitStatus.resetAt
|
||||
});
|
||||
|
||||
// Try to find a better profile
|
||||
const bestProfile = profileManager.getBestAvailableProfile(activeProfile.id);
|
||||
|
||||
if (bestProfile) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] Using alternative profile:', {
|
||||
originalProfile: activeProfile.name,
|
||||
alternativeProfile: bestProfile.name,
|
||||
reason: swapReason
|
||||
});
|
||||
}
|
||||
debugLog('[RateLimitDetector] Using alternative profile:', {
|
||||
originalProfile: activeProfile.name,
|
||||
alternativeProfile: bestProfile.name,
|
||||
reason: swapReason
|
||||
});
|
||||
|
||||
// Persist the swap by updating the active profile
|
||||
// This ensures the UI reflects which account is actually being used
|
||||
@@ -564,6 +569,14 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
|
||||
const profileEnv = profileManager.getProfileEnv(bestProfile.id);
|
||||
|
||||
debugLog('[RateLimitDetector] Profile env for swapped profile:', {
|
||||
profileId: bestProfile.id,
|
||||
hasClaudeConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: profileEnv.CLAUDE_CONFIG_DIR,
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
envKeys: Object.keys(profileEnv),
|
||||
});
|
||||
|
||||
return {
|
||||
env: ensureCleanProfileEnv(profileEnv),
|
||||
profileId: bestProfile.id,
|
||||
@@ -576,14 +589,21 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
|
||||
}
|
||||
debugLog('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
|
||||
}
|
||||
}
|
||||
|
||||
// Use active profile (either it's fine, or no better alternative exists)
|
||||
const activeEnv = profileManager.getActiveProfileEnv();
|
||||
|
||||
debugLog('[RateLimitDetector] Using active profile env (no swap):', {
|
||||
profileId: activeProfile.id,
|
||||
hasClaudeConfigDir: !!activeEnv.CLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: activeEnv.CLAUDE_CONFIG_DIR,
|
||||
hasOAuthToken: !!activeEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
envKeys: Object.keys(activeEnv),
|
||||
});
|
||||
|
||||
return {
|
||||
env: ensureCleanProfileEnv(activeEnv),
|
||||
profileId: activeProfile.id,
|
||||
@@ -595,23 +615,55 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
/**
|
||||
* Ensure the profile environment is clean for subprocess invocation.
|
||||
*
|
||||
* When CLAUDE_CONFIG_DIR is set, we MUST clear CLAUDE_CODE_OAUTH_TOKEN to prevent
|
||||
* the Claude Agent SDK from using a hardcoded/cached token (e.g., from .env file)
|
||||
* instead of reading fresh credentials from the specified config directory.
|
||||
* When CLAUDE_CONFIG_DIR is set, we MUST clear both CLAUDE_CODE_OAUTH_TOKEN and
|
||||
* ANTHROPIC_API_KEY to prevent the Claude Agent SDK from using hardcoded/cached
|
||||
* tokens or API keys (e.g., from .env file or shell environment) instead of reading
|
||||
* fresh credentials from the specified config directory.
|
||||
*
|
||||
* ANTHROPIC_API_KEY is cleared to prevent Claude Code from using API keys present
|
||||
* in the shell environment, which would cause it to show "Claude API" instead of
|
||||
* "Claude Max" and bypass the intended config dir credentials.
|
||||
*
|
||||
* This is critical for multi-account switching: when switching from a rate-limited
|
||||
* account to an available one, the subprocess must use the new account's credentials.
|
||||
*
|
||||
* Also warns if the profile env is empty, which indicates a misconfigured profile.
|
||||
*
|
||||
* @param env - Profile environment from getProfileEnv() or getActiveProfileEnv()
|
||||
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set
|
||||
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY cleared if CLAUDE_CONFIG_DIR is set
|
||||
*/
|
||||
function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
|
||||
export function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
|
||||
debugLog('[RateLimitDetector] ensureCleanProfileEnv() input:', {
|
||||
hasClaudeConfigDir: !!env.CLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: env.CLAUDE_CONFIG_DIR,
|
||||
hasOAuthToken: !!env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
willClearOAuthToken: !!env.CLAUDE_CONFIG_DIR,
|
||||
willClearApiKey: !!env.CLAUDE_CONFIG_DIR,
|
||||
});
|
||||
|
||||
// Warn if the profile environment is empty — this likely indicates a misconfigured profile
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.warn('[RateLimitDetector] ensureCleanProfileEnv() received empty profile env — profile may be misconfigured');
|
||||
}
|
||||
|
||||
if (env.CLAUDE_CONFIG_DIR) {
|
||||
// Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
|
||||
return {
|
||||
// Clear CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
|
||||
// ANTHROPIC_API_KEY must also be cleared to prevent Claude Code from using
|
||||
// API keys that may be present in the shell environment instead of the config dir credentials.
|
||||
const cleanedEnv = {
|
||||
...env,
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ''
|
||||
CLAUDE_CODE_OAUTH_TOKEN: '',
|
||||
ANTHROPIC_API_KEY: ''
|
||||
};
|
||||
|
||||
debugLog('[RateLimitDetector] ensureCleanProfileEnv() output:', {
|
||||
claudeConfigDirPreserved: 'CLAUDE_CONFIG_DIR' in cleanedEnv,
|
||||
claudeConfigDir: (cleanedEnv as Record<string, string>).CLAUDE_CONFIG_DIR,
|
||||
oauthTokenCleared: cleanedEnv.CLAUDE_CODE_OAUTH_TOKEN === '',
|
||||
envKeys: Object.keys(cleanedEnv),
|
||||
});
|
||||
|
||||
return cleanedEnv;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -262,11 +262,16 @@ export function setupPtyHandlers(
|
||||
|
||||
/**
|
||||
* Constants for chunked write behavior
|
||||
* CHUNKED_WRITE_THRESHOLD: Data larger than this (bytes) will be written in chunks
|
||||
* CHUNK_SIZE: Size of each chunk - smaller chunks yield to event loop more frequently
|
||||
* CHUNKED_WRITE_THRESHOLD: Data larger than this (bytes) will be written in chunks.
|
||||
* Set high enough that typical pastes go through as a single synchronous write.
|
||||
* CHUNK_SIZE: Size of each chunk. Larger chunks = fewer event-loop yields = less
|
||||
* GPU pressure when many terminals are rendering simultaneously.
|
||||
* Previous values (1000/100) caused GPU context exhaustion: a 9KB paste produced
|
||||
* ~91 setImmediate yields, letting GPU rendering tasks from 8+ terminals pile up
|
||||
* until ContextResult::kTransientFailure crashed the app.
|
||||
*/
|
||||
const CHUNKED_WRITE_THRESHOLD = 1000;
|
||||
const CHUNK_SIZE = 100;
|
||||
const CHUNKED_WRITE_THRESHOLD = 16_384;
|
||||
const CHUNK_SIZE = 8_192;
|
||||
|
||||
/**
|
||||
* Write queue per terminal to prevent interleaving of concurrent writes.
|
||||
|
||||
@@ -376,6 +376,10 @@ export interface PRReviewFinding {
|
||||
endLine?: number;
|
||||
suggestedFix?: string;
|
||||
fixable: boolean;
|
||||
validationStatus?: 'confirmed_valid' | 'dismissed_false_positive' | 'needs_human_review' | null;
|
||||
validationExplanation?: string;
|
||||
sourceAgents?: string[];
|
||||
crossValidated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +391,7 @@ export interface PRReviewResult {
|
||||
success: boolean;
|
||||
findings: PRReviewFinding[];
|
||||
summary: string;
|
||||
overallStatus: 'approve' | 'request_changes' | 'comment';
|
||||
overallStatus: 'approve' | 'request_changes' | 'comment' | 'in_progress';
|
||||
reviewId?: number;
|
||||
reviewedAt: string;
|
||||
error?: string;
|
||||
@@ -403,6 +407,8 @@ export interface PRReviewResult {
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
// In-progress review tracking
|
||||
inProgressSince?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Sparkles,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Heart,
|
||||
Wrench,
|
||||
PanelLeft,
|
||||
PanelLeftClose
|
||||
@@ -452,6 +453,26 @@ export function Sidebar({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Sponsor link */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => window.open('https://github.com/sponsors/AndyMik90', '_blank')}
|
||||
className={cn(
|
||||
'flex w-full items-center text-xs transition-colors',
|
||||
'text-amber-500/70 hover:text-amber-400',
|
||||
isCollapsed ? 'justify-center' : 'gap-1.5 px-3'
|
||||
)}
|
||||
>
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
{!isCollapsed && <span>{t('actions.sponsor')}</span>}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{isCollapsed && (
|
||||
<TooltipContent side="right">{t('actions.sponsor')}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/* New Task button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -66,6 +66,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
isExternalReview,
|
||||
previousReviewResult,
|
||||
hasMore,
|
||||
selectPR,
|
||||
@@ -269,6 +270,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
reviewProgress={reviewProgress}
|
||||
startedAt={startedAt}
|
||||
isReviewing={isReviewing}
|
||||
isExternalReview={isExternalReview}
|
||||
initialNewCommitsCheck={storedNewCommitsCheck}
|
||||
isActive={isActive}
|
||||
isLoadingFiles={isLoadingPRDetails}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface FindingItemProps {
|
||||
finding: PRReviewFinding;
|
||||
selected: boolean;
|
||||
posted?: boolean;
|
||||
disputed?: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ function getCategoryTranslationKey(category: string): string {
|
||||
return categoryMap[category.toLowerCase()] || category;
|
||||
}
|
||||
|
||||
export function FindingItem({ finding, selected, posted = false, onToggle }: FindingItemProps) {
|
||||
export function FindingItem({ finding, selected, posted = false, disputed = false, onToggle }: FindingItemProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const CategoryIcon = getCategoryIcon(finding.category);
|
||||
|
||||
@@ -45,8 +46,9 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-background p-3 space-y-2 transition-colors",
|
||||
selected && !posted && "ring-2 ring-primary/50",
|
||||
posted && "opacity-60"
|
||||
selected && !posted && !disputed && "ring-2 ring-primary/50",
|
||||
selected && disputed && "ring-2 ring-purple-500/50",
|
||||
(posted || (disputed && !selected)) && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{/* Finding Header */}
|
||||
@@ -72,6 +74,16 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
|
||||
{t('prReview.posted')}
|
||||
</Badge>
|
||||
)}
|
||||
{disputed && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 bg-purple-500/10 text-purple-500 border-purple-500/30">
|
||||
{t('prReview.disputed')}
|
||||
</Badge>
|
||||
)}
|
||||
{finding.crossValidated && finding.sourceAgents && finding.sourceAgents.length > 1 && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 bg-green-500/10 text-green-500 border-green-500/30">
|
||||
{t('prReview.crossValidatedBy', { count: finding.sourceAgents.length })}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="font-medium text-sm break-words">
|
||||
{finding.title}
|
||||
</span>
|
||||
@@ -79,6 +91,11 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
{finding.description}
|
||||
</p>
|
||||
{disputed && finding.validationExplanation && (
|
||||
<p className="text-xs text-purple-500/80 italic break-words">
|
||||
{finding.validationExplanation}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<code className="bg-muted px-1 py-0.5 rounded break-all">
|
||||
{finding.file}:{finding.line}
|
||||
|
||||
@@ -9,9 +9,10 @@ import type { PRReviewFinding } from '../hooks/useGitHubPRs';
|
||||
interface FindingsSummaryProps {
|
||||
findings: PRReviewFinding[];
|
||||
selectedCount: number;
|
||||
disputedCount?: number;
|
||||
}
|
||||
|
||||
export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProps) {
|
||||
export function FindingsSummary({ findings, selectedCount, disputedCount = 0 }: FindingsSummaryProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Count findings by severity
|
||||
@@ -46,6 +47,11 @@ export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProp
|
||||
{counts.low} {t('prReview.severity.low')}
|
||||
</Badge>
|
||||
)}
|
||||
{disputedCount > 0 && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/30">
|
||||
{disputedCount} {t('prReview.disputed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('prReview.selectedOfTotal', { selected: selectedCount, total: counts.total })}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { PRLogs } from './PRLogs';
|
||||
|
||||
import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck, MergeReadiness, PRLogs as PRLogsType, WorkflowsAwaitingApprovalResult } from '../../../../preload/api/modules/github-api';
|
||||
import { usePRReviewStore } from '../../../stores/github';
|
||||
|
||||
interface PRDetailProps {
|
||||
pr: PRData;
|
||||
@@ -44,6 +45,7 @@ interface PRDetailProps {
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
startedAt: string | null;
|
||||
isReviewing: boolean;
|
||||
isExternalReview?: boolean;
|
||||
initialNewCommitsCheck?: NewCommitsCheck | null;
|
||||
isActive?: boolean;
|
||||
isLoadingFiles?: boolean;
|
||||
@@ -78,6 +80,7 @@ export function PRDetail({
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
isExternalReview = false,
|
||||
initialNewCommitsCheck,
|
||||
isActive: _isActive = false,
|
||||
isLoadingFiles = false,
|
||||
@@ -398,6 +401,59 @@ export function PRDetail({
|
||||
};
|
||||
}, [isReviewing, onGetLogs]);
|
||||
|
||||
/**
|
||||
* Completion detection for external (in-progress) reviews
|
||||
*
|
||||
* When the backend reports overallStatus === 'in_progress', the store sets
|
||||
* isExternalReview = true and isReviewing = true. This effect polls the
|
||||
* review result file every 3 seconds to detect when the external review
|
||||
* finishes. Once a completed result is found (overallStatus !== 'in_progress'),
|
||||
* we update the store which will set isReviewing = false and display the result.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isReviewing || !isExternalReview) return;
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const pollStart = Date.now();
|
||||
|
||||
const pollForCompletion = async () => {
|
||||
// Timeout: stop polling after 30 minutes to avoid indefinite polling
|
||||
if (Date.now() - pollStart > MAX_POLL_DURATION_MS) {
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, {
|
||||
prNumber: pr.number,
|
||||
repo: '',
|
||||
success: false,
|
||||
findings: [],
|
||||
summary: '',
|
||||
overallStatus: 'comment',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
error: 'External review polling timed out after 30 minutes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.github.getPRReview(projectId, pr.number);
|
||||
if (result && result.overallStatus !== 'in_progress') {
|
||||
// Only accept results that were produced AFTER we detected the external review.
|
||||
// Otherwise this is a stale result from a previous review still on disk
|
||||
// (in-progress results are intentionally NOT saved to disk).
|
||||
if (startedAt && result.reviewedAt && new Date(result.reviewedAt) > new Date(startedAt)) {
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, result);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors — transient file read failures shouldn't stop polling
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately, then every 3 seconds
|
||||
pollForCompletion();
|
||||
const interval = setInterval(pollForCompletion, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [isReviewing, isExternalReview, projectId, pr.number, startedAt]);
|
||||
|
||||
/**
|
||||
* Fallback mechanism: Load logs after review completes if not already loaded
|
||||
*
|
||||
@@ -1067,6 +1123,7 @@ ${t('prReview.blockedStatusMessageFooter')}`;
|
||||
<ReviewStatusTree
|
||||
status={prStatus.status}
|
||||
isReviewing={isReviewing}
|
||||
isExternalReview={isExternalReview}
|
||||
startedAt={startedAt}
|
||||
reviewResult={reviewResult}
|
||||
previousReviewResult={previousReviewResult}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - Quick select actions (Critical/High, All, None)
|
||||
* - Collapsible sections for less important findings
|
||||
* - Visual summary of finding counts
|
||||
* - Disputed findings shown in a separate collapsible section
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
CheckSquare,
|
||||
Square,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ShieldQuestion,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -47,6 +51,7 @@ export function ReviewFindings({
|
||||
const [expandedSections, setExpandedSections] = useState<Set<SeverityGroup>>(
|
||||
new Set<SeverityGroup>(['critical', 'high']) // Critical and High expanded by default
|
||||
);
|
||||
const [disputedExpanded, setDisputedExpanded] = useState(false);
|
||||
|
||||
// Filter out posted findings - only show unposted findings for selection
|
||||
const unpostedFindings = useMemo(() =>
|
||||
@@ -54,10 +59,24 @@ export function ReviewFindings({
|
||||
[findings, postedIds]
|
||||
);
|
||||
|
||||
// Split unposted findings into active vs disputed (single pass)
|
||||
const { activeFindings, disputedFindings } = useMemo(() => {
|
||||
const active: PRReviewFinding[] = [];
|
||||
const disputed: PRReviewFinding[] = [];
|
||||
for (const finding of unpostedFindings) {
|
||||
if (finding.validationStatus === 'dismissed_false_positive') {
|
||||
disputed.push(finding);
|
||||
} else {
|
||||
active.push(finding);
|
||||
}
|
||||
}
|
||||
return { activeFindings: active, disputedFindings: disputed };
|
||||
}, [unpostedFindings]);
|
||||
|
||||
// Check if all findings are posted
|
||||
const allFindingsPosted = findings.length > 0 && unpostedFindings.length === 0;
|
||||
|
||||
// Group unposted findings by severity (only show findings that haven't been posted)
|
||||
// Group ACTIVE unposted findings by severity (disputed go in their own section)
|
||||
const groupedFindings = useMemo(() => {
|
||||
const groups: Record<SeverityGroup, PRReviewFinding[]> = {
|
||||
critical: [],
|
||||
@@ -66,7 +85,7 @@ export function ReviewFindings({
|
||||
low: [],
|
||||
};
|
||||
|
||||
for (const finding of unpostedFindings) {
|
||||
for (const finding of activeFindings) {
|
||||
const severity = finding.severity as SeverityGroup;
|
||||
if (groups[severity]) {
|
||||
groups[severity].push(finding);
|
||||
@@ -74,20 +93,20 @@ export function ReviewFindings({
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [unpostedFindings]);
|
||||
}, [activeFindings]);
|
||||
|
||||
// Count by severity (unposted findings only)
|
||||
// Count by severity (active findings only)
|
||||
const counts = useMemo(() => ({
|
||||
critical: groupedFindings.critical.length,
|
||||
high: groupedFindings.high.length,
|
||||
medium: groupedFindings.medium.length,
|
||||
low: groupedFindings.low.length,
|
||||
total: unpostedFindings.length,
|
||||
total: activeFindings.length,
|
||||
important: groupedFindings.critical.length + groupedFindings.high.length,
|
||||
posted: postedIds.size,
|
||||
}), [groupedFindings, unpostedFindings.length, postedIds.size]);
|
||||
}), [groupedFindings, activeFindings.length, postedIds.size]);
|
||||
|
||||
// Selection hooks - use unposted findings only
|
||||
// Selection hooks - use ACTIVE unposted findings only (Select All excludes disputed)
|
||||
const {
|
||||
toggleFinding,
|
||||
selectAll,
|
||||
@@ -95,7 +114,7 @@ export function ReviewFindings({
|
||||
selectImportant,
|
||||
toggleSeverityGroup,
|
||||
} = useFindingSelection({
|
||||
findings: unpostedFindings,
|
||||
findings: activeFindings,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
groupedFindings,
|
||||
@@ -114,6 +133,12 @@ export function ReviewFindings({
|
||||
});
|
||||
};
|
||||
|
||||
// Count only active findings that are selected (excludes disputed from count)
|
||||
const selectedActiveCount = useMemo(
|
||||
() => activeFindings.filter(f => selectedIds.has(f.id)).length,
|
||||
[activeFindings, selectedIds]
|
||||
);
|
||||
|
||||
// When all findings have been posted, show a success message instead of the selection UI
|
||||
if (allFindingsPosted) {
|
||||
return (
|
||||
@@ -131,10 +156,11 @@ export function ReviewFindings({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Stats Bar - show unposted findings only */}
|
||||
{/* Summary Stats Bar - show active findings + disputed count */}
|
||||
<FindingsSummary
|
||||
findings={unpostedFindings}
|
||||
selectedCount={selectedIds.size}
|
||||
findings={activeFindings}
|
||||
selectedCount={selectedActiveCount}
|
||||
disputedCount={disputedFindings.length}
|
||||
/>
|
||||
|
||||
{/* Quick Select Actions */}
|
||||
@@ -170,7 +196,7 @@ export function ReviewFindings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grouped Findings (unposted only) */}
|
||||
{/* Grouped Findings (active only) */}
|
||||
<div className="space-y-3">
|
||||
{SEVERITY_ORDER.map((severity) => {
|
||||
const group = groupedFindings[severity];
|
||||
@@ -220,6 +246,48 @@ export function ReviewFindings({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Disputed Findings Section */}
|
||||
{disputedFindings.length > 0 && (
|
||||
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5">
|
||||
{/* Disputed Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDisputedExpanded(!disputedExpanded)}
|
||||
aria-expanded={disputedExpanded}
|
||||
className="w-full flex items-center gap-2 p-3 text-left hover:bg-purple-500/10 transition-colors rounded-t-lg"
|
||||
>
|
||||
{disputedExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-purple-500 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-purple-500 shrink-0" />
|
||||
)}
|
||||
<ShieldQuestion className="h-4 w-4 text-purple-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-purple-500">
|
||||
{t('prReview.disputedByValidator', { count: disputedFindings.length })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Disputed Content */}
|
||||
{disputedExpanded && (
|
||||
<div className="p-3 pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground italic mb-2">
|
||||
{t('prReview.disputedSectionHint')}
|
||||
</p>
|
||||
{disputedFindings.map((finding) => (
|
||||
<FindingItem
|
||||
key={finding.id}
|
||||
finding={finding}
|
||||
selected={selectedIds.has(finding.id)}
|
||||
posted={false}
|
||||
disputed
|
||||
onToggle={() => toggleFinding(finding.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - no findings at all */}
|
||||
{findings.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ReviewStatus =
|
||||
export interface ReviewStatusTreeProps {
|
||||
status: ReviewStatus;
|
||||
isReviewing: boolean;
|
||||
isExternalReview?: boolean;
|
||||
startedAt: string | null;
|
||||
reviewResult: PRReviewResult | null;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
@@ -39,6 +40,7 @@ export interface ReviewStatusTreeProps {
|
||||
export function ReviewStatusTree({
|
||||
status,
|
||||
isReviewing,
|
||||
isExternalReview = false,
|
||||
startedAt,
|
||||
reviewResult,
|
||||
previousReviewResult,
|
||||
@@ -137,7 +139,9 @@ export function ReviewStatusTree({
|
||||
if (isReviewing) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisInProgress'),
|
||||
label: isExternalReview
|
||||
? t('prReview.reviewStartedExternally')
|
||||
: t('prReview.analysisInProgress'),
|
||||
status: 'current',
|
||||
date: null
|
||||
});
|
||||
@@ -255,7 +259,7 @@ export function ReviewStatusTree({
|
||||
|
||||
// Status label - explicitly handle all statuses
|
||||
const getStatusLabel = (): string => {
|
||||
if (isReviewing) return t('prReview.aiReviewInProgress');
|
||||
if (isReviewing) return isExternalReview ? t('prReview.externalReviewDetected') : t('prReview.aiReviewInProgress');
|
||||
switch (status) {
|
||||
case 'ready_to_merge':
|
||||
return t('prReview.readyToMerge');
|
||||
@@ -279,7 +283,7 @@ export function ReviewStatusTree({
|
||||
<CollapsibleCard
|
||||
title={statusLabel}
|
||||
icon={<div className={statusDotColor} />}
|
||||
headerAction={isReviewing ? (
|
||||
headerAction={isReviewing && !isExternalReview ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -30,21 +30,31 @@ export function useFindingSelection({
|
||||
onSelectionChange(next);
|
||||
}, [selectedIds, onSelectionChange]);
|
||||
|
||||
// Select all findings
|
||||
// Select all findings (preserving any disputed selections not in active findings)
|
||||
const selectAll = useCallback(() => {
|
||||
onSelectionChange(new Set(findings.map(f => f.id)));
|
||||
}, [findings, onSelectionChange]);
|
||||
const activeIds = new Set(findings.map(f => f.id));
|
||||
// Preserve selections for disputed findings (IDs not in active findings list)
|
||||
for (const id of selectedIds) {
|
||||
if (!findings.some(f => f.id === id)) activeIds.add(id);
|
||||
}
|
||||
onSelectionChange(activeIds);
|
||||
}, [findings, selectedIds, onSelectionChange]);
|
||||
|
||||
// Clear all selections
|
||||
const selectNone = useCallback(() => {
|
||||
onSelectionChange(new Set());
|
||||
}, [onSelectionChange]);
|
||||
|
||||
// Select only critical and high severity findings
|
||||
// Select only critical and high severity findings (preserving disputed selections)
|
||||
const selectImportant = useCallback(() => {
|
||||
const important = [...groupedFindings.critical, ...groupedFindings.high];
|
||||
onSelectionChange(new Set(important.map(f => f.id)));
|
||||
}, [groupedFindings, onSelectionChange]);
|
||||
const importantIds = new Set(important.map(f => f.id));
|
||||
// Preserve selections for disputed findings (IDs not in active findings list)
|
||||
for (const id of selectedIds) {
|
||||
if (!findings.some(f => f.id === id)) importantIds.add(id);
|
||||
}
|
||||
onSelectionChange(importantIds);
|
||||
}, [groupedFindings, findings, selectedIds, onSelectionChange]);
|
||||
|
||||
// Toggle entire severity group selection
|
||||
const toggleSeverityGroup = useCallback((severity: SeverityGroup) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ interface UseGitHubPRsResult {
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
startedAt: string | null;
|
||||
isReviewing: boolean;
|
||||
isExternalReview: boolean;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
isConnected: boolean;
|
||||
repoFullName: string | null;
|
||||
@@ -113,6 +114,7 @@ export function useGitHubPRs(
|
||||
const reviewResult = selectedPRReviewState?.result ?? null;
|
||||
const reviewProgress = selectedPRReviewState?.progress ?? null;
|
||||
const isReviewing = selectedPRReviewState?.isReviewing ?? false;
|
||||
const isExternalReview = selectedPRReviewState?.isExternalReview ?? false;
|
||||
const previousReviewResult = selectedPRReviewState?.previousResult ?? null;
|
||||
const startedAt = selectedPRReviewState?.startedAt ?? null;
|
||||
|
||||
@@ -727,6 +729,7 @@ export function useGitHubPRs(
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
isExternalReview,
|
||||
previousReviewResult,
|
||||
isConnected,
|
||||
repoFullName,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CheckCircle2, Circle, ExternalLink, Play, TrendingUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Circle, ExternalLink, Play, TrendingUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TaskOutcomeBadge } from './TaskOutcomeBadge';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -7,6 +9,8 @@ import { Progress } from '../ui/progress';
|
||||
import { ROADMAP_PRIORITY_COLORS } from '../../../shared/constants';
|
||||
import type { PhaseCardProps } from './types';
|
||||
|
||||
const INITIAL_VISIBLE_COUNT = 5;
|
||||
|
||||
export function PhaseCard({
|
||||
phase,
|
||||
features,
|
||||
@@ -15,8 +19,13 @@ export function PhaseCard({
|
||||
onConvertToSpec,
|
||||
onGoToTask,
|
||||
}: PhaseCardProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const completedCount = features.filter((f) => f.status === 'done').length;
|
||||
const progress = features.length > 0 ? (completedCount / features.length) * 100 : 0;
|
||||
const visibleFeatures = isExpanded ? features : features.slice(0, INITIAL_VISIBLE_COUNT);
|
||||
const hiddenCount = features.length - INITIAL_VISIBLE_COUNT;
|
||||
const hasMoreFeatures = hiddenCount > 0;
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
@@ -87,13 +96,16 @@ export function PhaseCard({
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Features ({features.length})</h4>
|
||||
<div className="grid gap-2">
|
||||
{features.slice(0, 5).map((feature) => (
|
||||
{visibleFeatures.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted cursor-pointer transition-colors"
|
||||
onClick={() => onFeatureSelect(feature)}
|
||||
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={() => onFeatureSelect(feature)}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${ROADMAP_PRIORITY_COLORS[feature.priority]}`}
|
||||
@@ -104,7 +116,7 @@ export function PhaseCard({
|
||||
{feature.competitorInsightIds && feature.competitorInsightIds.length > 0 && (
|
||||
<TrendingUp className="h-3 w-3 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{feature.taskOutcome ? (
|
||||
<span className="flex-shrink-0">
|
||||
<TaskOutcomeBadge outcome={feature.taskOutcome} size="lg" showLabel={false} />
|
||||
@@ -140,10 +152,26 @@ export function PhaseCard({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{features.length > 5 && (
|
||||
<div className="text-sm text-muted-foreground text-center py-1">
|
||||
+{features.length - 5} more features
|
||||
</div>
|
||||
{hasMoreFeatures && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center justify-center gap-1 text-sm text-muted-foreground hover:text-foreground w-full"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
{t('roadmap.showLessFeatures')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
{t('roadmap.showMoreFeatures', { count: hiddenCount })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,11 +139,18 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
};
|
||||
|
||||
// Helper function to handle paste from clipboard
|
||||
// Cap paste size to prevent GPU/memory pressure from extremely large clipboard contents.
|
||||
const MAX_PASTE_BYTES = 1_048_576; // 1 MB
|
||||
const handlePasteFromClipboard = (): void => {
|
||||
navigator.clipboard.readText()
|
||||
.then((text) => {
|
||||
if (text) {
|
||||
xterm.paste(text);
|
||||
if (text.length > MAX_PASTE_BYTES) {
|
||||
console.warn(`[useXterm] Paste truncated from ${text.length} to ${MAX_PASTE_BYTES} bytes`);
|
||||
xterm.paste(text.slice(0, MAX_PASTE_BYTES));
|
||||
} else {
|
||||
xterm.paste(text);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useAuthFailureStore } from '../stores/auth-failure-store';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo, AuthFailureInfo } from '../../shared/types';
|
||||
|
||||
/** Maximum log entries to buffer in the batch queue between flushes (OOM prevention) */
|
||||
const MAX_BATCH_QUEUE_LOGS = 100;
|
||||
|
||||
/**
|
||||
* Batched update queue for IPC events.
|
||||
* Collects updates within a 16ms window (one frame) and flushes them together.
|
||||
@@ -124,6 +127,10 @@ function queueUpdate(taskId: string, update: BatchedUpdate): void {
|
||||
let mergedLogs = existing.logs;
|
||||
if (update.logs) {
|
||||
mergedLogs = [...(existing.logs || []), ...update.logs];
|
||||
// Cap batch queue logs to prevent OOM when logs arrive faster than flush interval
|
||||
if (mergedLogs.length > MAX_BATCH_QUEUE_LOGS) {
|
||||
mergedLogs = mergedLogs.slice(-MAX_BATCH_QUEUE_LOGS);
|
||||
}
|
||||
}
|
||||
|
||||
batchQueue.set(taskId, {
|
||||
|
||||
@@ -34,20 +34,6 @@ describe('task-store-persistence', () => {
|
||||
let useTaskStore: typeof import('../task-store').useTaskStore;
|
||||
let loadTasks: typeof import('../task-store').loadTasks;
|
||||
let createTask: typeof import('../task-store').createTask;
|
||||
// Helper to create test tasks with all required fields
|
||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: 'task-1',
|
||||
specId: '001-test-task',
|
||||
projectId: 'test-project',
|
||||
title: 'Test Task',
|
||||
description: 'Test description',
|
||||
status: 'backlog' as TaskStatus,
|
||||
logs: [],
|
||||
subtasks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ interface PRReviewState {
|
||||
mergeableState: MergeableState | null;
|
||||
/** Timestamp of last status poll (ISO 8601 string) */
|
||||
lastPolled: string | null;
|
||||
/** Whether this review was initiated externally (e.g., from PR list) rather than from detail view */
|
||||
isExternalReview: boolean;
|
||||
}
|
||||
|
||||
interface PRReviewStoreState {
|
||||
@@ -59,6 +61,8 @@ interface PRReviewStoreState {
|
||||
}) => void;
|
||||
/** Clear PR status fields for a specific PR */
|
||||
clearPRStatus: (projectId: string, prNumber: number) => void;
|
||||
/** Start an external review (from PR list) - sets isReviewing and isExternalReview */
|
||||
setExternalReviewInProgress: (projectId: string, prNumber: number, inProgressSince?: string) => void;
|
||||
|
||||
// Selectors
|
||||
getPRReviewState: (projectId: string, prNumber: number) => PRReviewState | null;
|
||||
@@ -96,7 +100,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -130,7 +135,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -155,7 +161,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: existing?.isExternalReview ?? false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -182,7 +189,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: existing?.isExternalReview ?? false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -207,7 +215,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: existing?.isExternalReview ?? false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -234,7 +243,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: null,
|
||||
reviewsStatus: null,
|
||||
mergeableState: null,
|
||||
lastPolled: null
|
||||
lastPolled: null,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -282,7 +292,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: status.checksStatus,
|
||||
reviewsStatus: status.reviewsStatus,
|
||||
mergeableState: status.mergeableState,
|
||||
lastPolled: status.lastPolled
|
||||
lastPolled: status.lastPolled,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -321,6 +332,32 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
setExternalReviewInProgress: (projectId: string, prNumber: number, inProgressSince?: string) => set((state) => {
|
||||
const key = `${projectId}:${prNumber}`;
|
||||
const existing = state.prReviews[key];
|
||||
return {
|
||||
prReviews: {
|
||||
...state.prReviews,
|
||||
[key]: {
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: true,
|
||||
startedAt: inProgressSince || new Date().toISOString(),
|
||||
progress: null,
|
||||
result: existing?.result ?? null,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
error: null,
|
||||
newCommitsCheck: existing?.newCommitsCheck ?? null,
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
// Selectors
|
||||
getPRReviewState: (projectId: string, prNumber: number) => {
|
||||
const { prReviews } = get();
|
||||
@@ -378,6 +415,15 @@ export function initializePRReviewListeners(): void {
|
||||
// Listen for PR review completion events
|
||||
const cleanupComplete = window.electronAPI.github.onPRReviewComplete(
|
||||
(projectId: string, result: PRReviewResult) => {
|
||||
// When the backend detects an already-running review (e.g., started from another
|
||||
// client or the PR list), it returns overallStatus === 'in_progress' instead of
|
||||
// a real result. Transition to external-review-in-progress so the log polling
|
||||
// activates and the UI shows the ongoing review.
|
||||
if (result.overallStatus === 'in_progress') {
|
||||
store.setExternalReviewInProgress(projectId, result.prNumber, result.inProgressSince);
|
||||
return;
|
||||
}
|
||||
|
||||
store.setPRReviewResult(projectId, result);
|
||||
// Trigger all registered refresh callbacks when review completes
|
||||
refreshCallbacks.forEach(callback => {
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useProjectStore } from './project-store';
|
||||
/** Default max parallel tasks when no project setting is configured */
|
||||
export const DEFAULT_MAX_PARALLEL_TASKS = 3;
|
||||
|
||||
/** Maximum log entries stored per task to prevent renderer OOM */
|
||||
export const MAX_LOG_ENTRIES = 5000;
|
||||
|
||||
interface TaskState {
|
||||
tasks: Task[];
|
||||
selectedTaskId: string | null;
|
||||
@@ -475,7 +478,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
return {
|
||||
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({
|
||||
...t,
|
||||
logs: [...(t.logs || []), log]
|
||||
logs: [...(t.logs || []), log].slice(-MAX_LOG_ENTRIES)
|
||||
}))
|
||||
};
|
||||
}),
|
||||
@@ -508,7 +511,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
return {
|
||||
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({
|
||||
...t,
|
||||
logs: [...(t.logs || []), ...logs]
|
||||
logs: [...(t.logs || []), ...logs].slice(-MAX_LOG_ENTRIES)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -292,6 +292,8 @@
|
||||
"newIssue": "{{count}} new issue",
|
||||
"newIssue_plural": "{{count}} new issues",
|
||||
"reviewFailed": "Review Failed",
|
||||
"externalReviewDetected": "External Review Detected",
|
||||
"reviewStartedExternally": "This review was started from another session",
|
||||
"description": "Description",
|
||||
"noDescription": "No description provided.",
|
||||
"followupReviewDetails": "Follow-up Review Details",
|
||||
@@ -400,6 +402,10 @@
|
||||
"verifyChanges": "Verify Changes",
|
||||
"verifyAnyway": "Verify",
|
||||
"runFollowupAnyway": "Run follow-up verification even though no files overlap",
|
||||
"disputed": "Disputed",
|
||||
"disputedByValidator": "Disputed by Validator ({{count}})",
|
||||
"crossValidatedBy": "Confirmed by {{count}} agents",
|
||||
"disputedSectionHint": "These findings were reported by specialists but disputed by the validator. You can still select and post them.",
|
||||
"logs": {
|
||||
"agentActivity": "Agent Activity",
|
||||
"showMore": "Show {{count}} more",
|
||||
@@ -597,7 +603,10 @@
|
||||
"roadmap": {
|
||||
"taskCompleted": "Completed",
|
||||
"taskDeleted": "Deleted",
|
||||
"taskArchived": "Archived"
|
||||
"taskArchived": "Archived",
|
||||
"showMoreFeatures": "Show {{count}} more feature",
|
||||
"showMoreFeatures_plural": "Show {{count}} more features",
|
||||
"showLessFeatures": "Show less"
|
||||
},
|
||||
"roadmapGeneration": {
|
||||
"progress": "Progress",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"help": "Help & Feedback",
|
||||
"newTask": "New Task",
|
||||
"collapseSidebar": "Collapse Sidebar",
|
||||
"expandSidebar": "Expand Sidebar"
|
||||
"expandSidebar": "Expand Sidebar",
|
||||
"sponsor": "Sponsor Us"
|
||||
},
|
||||
"tooltips": {
|
||||
"settings": "Application Settings",
|
||||
|
||||
@@ -292,6 +292,8 @@
|
||||
"newIssue": "{{count}} nouveau problème",
|
||||
"newIssue_plural": "{{count}} nouveaux problèmes",
|
||||
"reviewFailed": "Révision échouée",
|
||||
"externalReviewDetected": "Révision externe détectée",
|
||||
"reviewStartedExternally": "Cette révision a été lancée depuis une autre session",
|
||||
"description": "Description",
|
||||
"noDescription": "Aucune description fournie.",
|
||||
"followupReviewDetails": "Détails de la révision de suivi",
|
||||
@@ -400,6 +402,10 @@
|
||||
"blockedStatusMessageTitle": "## 🤖 Auto Claude PR Review",
|
||||
"blockedStatusMessageFooter": "*This review identified blockers that must be resolved before merge. Generated by Auto Claude.*",
|
||||
"failedPostBlockedStatus": "Échec de la publication du statut",
|
||||
"disputed": "Contesté",
|
||||
"disputedByValidator": "Contesté par le validateur ({{count}})",
|
||||
"crossValidatedBy": "Confirmé par {{count}} agents",
|
||||
"disputedSectionHint": "Ces résultats ont été signalés par les spécialistes mais contestés par le validateur. Vous pouvez toujours les sélectionner et les publier.",
|
||||
"logs": {
|
||||
"agentActivity": "Activité des agents",
|
||||
"showMore": "Afficher {{count}} de plus",
|
||||
@@ -597,7 +603,10 @@
|
||||
"roadmap": {
|
||||
"taskCompleted": "Terminé",
|
||||
"taskDeleted": "Supprimé",
|
||||
"taskArchived": "Archivé"
|
||||
"taskArchived": "Archivé",
|
||||
"showMoreFeatures": "Afficher {{count}} fonctionnalité supplémentaire",
|
||||
"showMoreFeatures_plural": "Afficher {{count}} fonctionnalités supplémentaires",
|
||||
"showLessFeatures": "Afficher moins"
|
||||
},
|
||||
"roadmapGeneration": {
|
||||
"progress": "Progression",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"help": "Aide & Feedback",
|
||||
"newTask": "Nouvelle tâche",
|
||||
"collapseSidebar": "Réduire la barre latérale",
|
||||
"expandSidebar": "Développer la barre latérale"
|
||||
"expandSidebar": "Développer la barre latérale",
|
||||
"sponsor": "Nous sponsoriser"
|
||||
},
|
||||
"tooltips": {
|
||||
"settings": "Paramètres de l'application",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude",
|
||||
"version": "2.7.6-beta.4",
|
||||
"version": "2.7.6-beta.5",
|
||||
"description": "Autonomous multi-agent coding framework powered by Claude AI",
|
||||
"license": "AGPL-3.0",
|
||||
"author": "Auto Claude Team",
|
||||
@@ -20,6 +20,7 @@
|
||||
"lint": "cd apps/frontend && npm run lint",
|
||||
"test": "cd apps/frontend && npm test",
|
||||
"test:backend": "node scripts/test-backend.js",
|
||||
"test:coverage": "node scripts/test-backend.js --cov --cov-report=term-missing --cov-report=html",
|
||||
"package": "cd apps/frontend && npm run package",
|
||||
"package:mac": "cd apps/frontend && npm run package:mac",
|
||||
"package:win": "cd apps/frontend && npm run package:win",
|
||||
|
||||
+21
-6
@@ -4,7 +4,7 @@
|
||||
* Runs pytest using the correct virtual environment path for Windows/Mac/Linux
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { execFileSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
@@ -39,15 +39,30 @@ if (!fs.existsSync(pytestPath)) {
|
||||
}
|
||||
|
||||
// Get any additional args passed to the script
|
||||
// Process args to properly handle -m flag with spaces
|
||||
const args = process.argv.slice(2);
|
||||
const testArgs = args.length > 0 ? args.join(' ') : '-v';
|
||||
const testArgs = [];
|
||||
|
||||
// Run pytest
|
||||
const cmd = `"${pytestPath}" "${testsDir}" ${testArgs}`;
|
||||
console.log(`> ${cmd}\n`);
|
||||
if (args.length > 0) {
|
||||
// Reconstruct args, joining -m with its value if separated
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '-m' && i + 1 < args.length) {
|
||||
// Pass -m and its value as separate args (no shell quoting needed with execFileSync)
|
||||
testArgs.push('-m', args[i + 1]);
|
||||
i++; // Skip next arg since we consumed it
|
||||
} else {
|
||||
testArgs.push(args[i]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
testArgs.push('-v');
|
||||
}
|
||||
|
||||
// Run pytest using execFileSync to avoid shell interpretation
|
||||
console.log(`> ${pytestPath} "${testsDir}" ${testArgs.join(' ')}\n`);
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: rootDir });
|
||||
execFileSync(pytestPath, [testsDir, ...testArgs], { stdio: 'inherit', cwd: rootDir });
|
||||
} catch (error) {
|
||||
process.exit(error.status || 1);
|
||||
}
|
||||
|
||||
+370
-18
@@ -42,6 +42,12 @@ if 'claude_code_sdk' not in sys.modules:
|
||||
sys.modules['claude_code_sdk'] = _create_sdk_mock()
|
||||
sys.modules['claude_code_sdk.types'] = MagicMock()
|
||||
|
||||
# Pre-mock dotenv to prevent sys.exit() in cli.utils.import_dotenv
|
||||
# This is needed for CLI tests since cli.utils calls import_dotenv at module level
|
||||
if 'dotenv' not in sys.modules:
|
||||
sys.modules['dotenv'] = MagicMock()
|
||||
sys.modules['dotenv'].load_dotenv = MagicMock()
|
||||
|
||||
# Add apps/backend directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
|
||||
|
||||
@@ -57,6 +63,7 @@ _POTENTIALLY_MOCKED_MODULES = [
|
||||
'claude_code_sdk.types',
|
||||
'claude_agent_sdk',
|
||||
'claude_agent_sdk.types',
|
||||
'dotenv',
|
||||
'ui',
|
||||
'progress',
|
||||
'task_logger',
|
||||
@@ -106,19 +113,23 @@ def pytest_runtest_setup(item):
|
||||
|
||||
module_name = item.module.__name__
|
||||
|
||||
# Common mock sets - defined once to reduce duplication and maintenance burden
|
||||
QA_REPORT_MOCKS = {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'}
|
||||
SDK_MOCKS = {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'claude_agent_sdk.types'}
|
||||
|
||||
# Map of which test modules mock which specific modules
|
||||
# Each test module should only preserve the mocks it installed
|
||||
module_mocks = {
|
||||
'test_qa_criteria': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_report': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_report_iteration': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_report_recurring': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_report_project_detection': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_report_manual_plan': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_report_config': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'},
|
||||
'test_qa_loop': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'claude_agent_sdk.types'},
|
||||
'test_qa_criteria': QA_REPORT_MOCKS,
|
||||
'test_qa_report': QA_REPORT_MOCKS,
|
||||
'test_qa_report_iteration': QA_REPORT_MOCKS,
|
||||
'test_qa_report_recurring': QA_REPORT_MOCKS,
|
||||
'test_qa_report_project_detection': QA_REPORT_MOCKS,
|
||||
'test_qa_report_manual_plan': QA_REPORT_MOCKS,
|
||||
'test_qa_report_config': QA_REPORT_MOCKS,
|
||||
'test_qa_loop': SDK_MOCKS,
|
||||
'test_spec_pipeline': {'claude_code_sdk', 'claude_code_sdk.types', 'init', 'client', 'review', 'task_logger', 'ui', 'validate_spec'},
|
||||
'test_spec_complexity': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'claude_agent_sdk.types'},
|
||||
'test_spec_complexity': SDK_MOCKS,
|
||||
'test_spec_phases': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'graphiti_providers', 'validate_spec', 'client'},
|
||||
'test_qa_fixer': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client', 'agents.memory_manager', 'agents.base', 'core.error_utils', 'security.tool_input_validator', 'debug'},
|
||||
'test_qa_reviewer': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client', 'agents.memory_manager', 'agents.base', 'core.error_utils', 'security.tool_input_validator', 'debug', 'prompts_pkg', 'prompts_pkg.project_context'},
|
||||
@@ -154,16 +165,19 @@ def pytest_runtest_setup(item):
|
||||
if qa_module in sys.modules:
|
||||
try:
|
||||
importlib.reload(sys.modules[qa_module])
|
||||
except Exception:
|
||||
pass # Some modules may fail to reload due to circular imports
|
||||
except Exception as e:
|
||||
# Log reload failures - circular imports are expected but other errors should be visible
|
||||
import warnings
|
||||
warnings.warn(f'Failed to reload {qa_module}: {e}')
|
||||
# Reload review module chain
|
||||
for review_module in ['review.state', 'review.formatters', 'review']:
|
||||
if review_module in sys.modules:
|
||||
try:
|
||||
importlib.reload(sys.modules[review_module])
|
||||
except Exception:
|
||||
# Module reload may fail if dependencies aren't loaded; safe to ignore
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log reload failures - some modules may fail if dependencies aren't loaded
|
||||
import warnings
|
||||
warnings.warn(f'Failed to reload {review_module}: {e}')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -257,6 +271,132 @@ def spec_dir(temp_dir: Path) -> Path:
|
||||
return spec_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WORKSPACE COMMAND TEST FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
# Constants for workspace tests
|
||||
TEST_SPEC_NAME = "001-test-spec"
|
||||
TEST_SPEC_BRANCH = f"auto-claude/{TEST_SPEC_NAME}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_dir(temp_git_repo: Path) -> Path:
|
||||
"""Create a mock project directory with git repo."""
|
||||
return temp_git_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worktree_path(temp_git_repo: Path) -> Path:
|
||||
"""Create a mock worktree path."""
|
||||
worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
|
||||
worktree_path.mkdir(parents=True, exist_ok=True)
|
||||
return worktree_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_spec_dir(temp_git_repo: Path) -> Path:
|
||||
"""Create a spec directory inside .auto-claude/specs/ for workspace tests."""
|
||||
spec_dir = temp_git_repo / ".auto-claude" / "specs" / TEST_SPEC_NAME
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_spec_branch(temp_git_repo: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temp git repo with a spec branch.
|
||||
|
||||
Note: temp_git_repo already provides an initialized repo with initial commit,
|
||||
so we only need to create the spec branch and add changes.
|
||||
"""
|
||||
# Create spec branch
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", TEST_SPEC_BRANCH],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Add a change on spec branch
|
||||
(temp_git_repo / "test.txt").write_text("test content")
|
||||
subprocess.run(
|
||||
["git", "add", "test.txt"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test commit"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Go back to main
|
||||
subprocess.run(
|
||||
["git", "checkout", "main"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
yield temp_git_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_conflicting_branches(temp_git_repo: Path) -> Generator[Path, None, None]:
|
||||
"""Create temp git repo with conflicting branches for merge testing.
|
||||
|
||||
Note: temp_git_repo already provides an initialized repo with initial commit,
|
||||
so we only need to create branches with conflicting changes.
|
||||
"""
|
||||
# Create spec branch
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", TEST_SPEC_BRANCH],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Add a file on spec branch
|
||||
(temp_git_repo / "conflict.txt").write_text("spec branch content")
|
||||
subprocess.run(
|
||||
["git", "add", "conflict.txt"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec change"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Go back to main and make conflicting change
|
||||
subprocess.run(
|
||||
["git", "checkout", "main"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
(temp_git_repo / "conflict.txt").write_text("main branch content")
|
||||
subprocess.run(
|
||||
["git", "add", "conflict.txt"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main change"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
yield temp_git_repo
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REVIEW FIXTURES - Import from review_fixtures.py
|
||||
# =============================================================================
|
||||
@@ -639,16 +779,15 @@ def mock_run_agent_fn():
|
||||
phase_name: str = None,
|
||||
) -> tuple[bool, str]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if side_effect is not None:
|
||||
if call_count < len(side_effect):
|
||||
result = side_effect[call_count]
|
||||
call_count += 1
|
||||
if call_count <= len(side_effect):
|
||||
result = side_effect[call_count - 1]
|
||||
return result
|
||||
# Fallback to last result if more calls than expected
|
||||
return side_effect[-1]
|
||||
return (success, output)
|
||||
|
||||
_mock_agent.call_count = 0
|
||||
return _mock_agent
|
||||
|
||||
return _create_mock
|
||||
@@ -706,6 +845,202 @@ def mock_ui_module():
|
||||
return ui
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ui_icons():
|
||||
"""
|
||||
Mock UI Icons class for CLI input handler tests.
|
||||
|
||||
Provides the complete Icons class with Unicode and ASCII fallbacks.
|
||||
Mirrors the structure in apps/backend/ui/icons.py.
|
||||
|
||||
Usage:
|
||||
def test_something(mock_ui_icons):
|
||||
Icons = mock_ui_icons
|
||||
assert Icons.SUCCESS == ("✓", "[OK]")
|
||||
"""
|
||||
class MockIcons:
|
||||
"""Mock Icons class - complete with all icons used by the codebase."""
|
||||
# Status icons
|
||||
SUCCESS = ("✓", "[OK]")
|
||||
ERROR = ("✗", "[X]")
|
||||
WARNING = ("⚠", "[!]")
|
||||
INFO = ("ℹ", "[i]")
|
||||
PENDING = ("○", "[ ]")
|
||||
IN_PROGRESS = ("◐", "[.]")
|
||||
COMPLETE = ("●", "[*]")
|
||||
BLOCKED = ("⊘", "[B]")
|
||||
|
||||
# Action icons
|
||||
PLAY = ("▶", ">")
|
||||
PAUSE = ("⏸", "||")
|
||||
STOP = ("⏹", "[]")
|
||||
SKIP = ("⏭", ">>")
|
||||
|
||||
# Navigation
|
||||
ARROW_RIGHT = ("→", "->")
|
||||
ARROW_DOWN = ("↓", "v")
|
||||
ARROW_UP = ("↑", "^")
|
||||
POINTER = ("❯", ">")
|
||||
BULLET = ("•", "*")
|
||||
|
||||
# Objects
|
||||
FOLDER = ("📁", "[D]")
|
||||
FILE = ("📄", "[F]")
|
||||
GEAR = ("⚙", "[*]")
|
||||
SEARCH = ("🔍", "[?]")
|
||||
BRANCH = ("🌿", "[BR]")
|
||||
COMMIT = ("◉", "(@)")
|
||||
LIGHTNING = ("⚡", "!")
|
||||
LINK = ("🔗", "[L]")
|
||||
|
||||
# Progress
|
||||
SUBTASK = ("▣", "#")
|
||||
PHASE = ("◆", "*")
|
||||
WORKER = ("⚡", "W")
|
||||
SESSION = ("▸", ">")
|
||||
|
||||
# Menu
|
||||
EDIT = ("✏️", "[E]")
|
||||
CLIPBOARD = ("📋", "[C]")
|
||||
DOCUMENT = ("📄", "[D]")
|
||||
DOOR = ("🚪", "[Q]")
|
||||
SHIELD = ("🛡️", "[S]")
|
||||
|
||||
# Box drawing
|
||||
BOX_TL = ("╔", "+")
|
||||
BOX_TR = ("╗", "+")
|
||||
BOX_BL = ("╚", "+")
|
||||
BOX_BR = ("╝", "+")
|
||||
BOX_H = ("═", "-")
|
||||
BOX_V = ("║", "|")
|
||||
BOX_ML = ("╠", "+")
|
||||
BOX_MR = ("╣", "+")
|
||||
BOX_TL_LIGHT = ("┌", "+")
|
||||
BOX_TR_LIGHT = ("┐", "+")
|
||||
BOX_BL_LIGHT = ("└", "+")
|
||||
BOX_BR_LIGHT = ("┘", "+")
|
||||
BOX_H_LIGHT = ("─", "-")
|
||||
BOX_V_LIGHT = ("│", "|")
|
||||
BOX_ML_LIGHT = ("├", "+")
|
||||
BOX_MR_LIGHT = ("┤", "+")
|
||||
|
||||
# Progress bar
|
||||
BAR_FULL = ("█", "=")
|
||||
BAR_EMPTY = ("░", "-")
|
||||
BAR_HALF = ("▌", "=")
|
||||
|
||||
return MockIcons
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ui_menu_option():
|
||||
"""
|
||||
Mock UI MenuOption class for CLI tests.
|
||||
|
||||
Provides a simple MenuOption class for menu testing.
|
||||
|
||||
Usage:
|
||||
def test_something(mock_ui_menu_option):
|
||||
option = mock_ui_menu_option()("key", "Label")
|
||||
assert option.key == "key"
|
||||
"""
|
||||
class MockMenuOption:
|
||||
"""Mock MenuOption class."""
|
||||
def __init__(self, key, label, icon=None, description=""):
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.icon = icon or ("", "")
|
||||
self.description = description
|
||||
|
||||
return MockMenuOption
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ui_module_full(mock_ui_icons, mock_ui_menu_option):
|
||||
"""
|
||||
Comprehensive mock UI module with all functions and classes.
|
||||
|
||||
Provides a complete mock of the ui module for CLI tests.
|
||||
Includes Icons, MenuOption, and all UI functions.
|
||||
|
||||
Usage:
|
||||
def test_something(mock_ui_module_full):
|
||||
ui = mock_ui_module_full
|
||||
assert ui.Icons.SUCCESS == ("✓", "[OK]")
|
||||
assert ui.icon(ui.Icons.SUCCESS) == "✓"
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
Icons = mock_ui_icons
|
||||
MenuOption = mock_ui_menu_option
|
||||
|
||||
def mock_icon(icon_tuple):
|
||||
"""Mock icon function."""
|
||||
return icon_tuple[0] if icon_tuple else ""
|
||||
|
||||
def mock_bold(text):
|
||||
"""Mock bold function."""
|
||||
return f"**{text}**"
|
||||
|
||||
def mock_muted(text):
|
||||
"""Mock muted function."""
|
||||
return f"[{text}]"
|
||||
|
||||
def mock_box(content, width=70, style="heavy"):
|
||||
"""Mock box function."""
|
||||
lines = ["┌" + "─" * (width - 2) + "┐"]
|
||||
for line in content:
|
||||
lines.append(f"│ {line} │")
|
||||
lines.append("└" + "─" * (width - 2) + "┘")
|
||||
return "\n".join(lines)
|
||||
|
||||
def mock_print_status(message, status="info"):
|
||||
"""Mock print_status function."""
|
||||
print(f"[{status.upper()}] {message}")
|
||||
|
||||
def mock_select_menu(title, options, subtitle="", allow_quit=True):
|
||||
"""Mock select_menu function."""
|
||||
return options[0].key if options else None
|
||||
|
||||
def mock_error(text):
|
||||
"""Mock error function."""
|
||||
return f"ERROR: {text}"
|
||||
|
||||
def mock_success(text):
|
||||
"""Mock success function."""
|
||||
return f"SUCCESS: {text}"
|
||||
|
||||
def mock_warning(text):
|
||||
"""Mock warning function."""
|
||||
return f"WARNING: {text}"
|
||||
|
||||
def mock_info(text):
|
||||
"""Mock info function."""
|
||||
return f"INFO: {text}"
|
||||
|
||||
def mock_highlight(text):
|
||||
"""Mock highlight function."""
|
||||
return text
|
||||
|
||||
# Create mock ui module
|
||||
mock_ui = MagicMock()
|
||||
mock_ui.Icons = Icons
|
||||
mock_ui.MenuOption = MenuOption
|
||||
mock_ui.icon = mock_icon
|
||||
mock_ui.bold = mock_bold
|
||||
mock_ui.muted = mock_muted
|
||||
mock_ui.box = mock_box
|
||||
mock_ui.print_status = mock_print_status
|
||||
mock_ui.select_menu = mock_select_menu
|
||||
mock_ui.error = mock_error
|
||||
mock_ui.success = mock_success
|
||||
mock_ui.warning = mock_warning
|
||||
mock_ui.info = mock_info
|
||||
mock_ui.highlight = mock_highlight
|
||||
|
||||
return mock_ui
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spec_validator():
|
||||
"""
|
||||
@@ -1240,6 +1575,23 @@ def temp_project_dir(tmp_path):
|
||||
return project_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def successful_agent_fn():
|
||||
"""
|
||||
Reusable async agent function that returns success.
|
||||
|
||||
Replaces the duplicated async def agent_fn(*args, **kwargs): return (True, 'Success')
|
||||
pattern that was copy-pasted 28 times across test_cli_build_commands.py.
|
||||
|
||||
Usage:
|
||||
def test_something(mock_run_agent, successful_agent_fn):
|
||||
mock_run_agent.side_effect = successful_agent_fn
|
||||
"""
|
||||
async def _fn(*args, **kwargs):
|
||||
return (True, 'Success')
|
||||
return _fn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worktree_manager(temp_project_dir):
|
||||
"""Create a WorktreeManager instance."""
|
||||
|
||||
@@ -629,7 +629,7 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, discovery):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
fake_dir = Path("/tmp/test-nonexistent-ci-discovery-123456")
|
||||
|
||||
# Should not raise - mock exists to avoid permission error
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Batch Commands
|
||||
=============================
|
||||
|
||||
Tests for batch_commands.py module functionality including:
|
||||
- handle_batch_create_command() - Create tasks from batch file
|
||||
- handle_batch_status_command() - Show status of all specs
|
||||
- handle_batch_cleanup_command() - Clean up completed specs
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
handle_batch_create_command,
|
||||
handle_batch_status_command,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_batch_file(temp_dir: Path) -> Path:
|
||||
"""Create a sample batch JSON file."""
|
||||
batch_data = {
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Add user authentication",
|
||||
"description": "Implement OAuth2 login with Google provider",
|
||||
"workflow_type": "feature",
|
||||
"services": ["backend", "frontend"],
|
||||
"priority": 8,
|
||||
"complexity": "standard",
|
||||
"estimated_hours": 6.0,
|
||||
"estimated_days": 0.75,
|
||||
},
|
||||
{
|
||||
"title": "Add payment processing",
|
||||
"description": "Integrate Stripe for payments",
|
||||
"workflow_type": "feature",
|
||||
"services": ["backend", "worker"],
|
||||
"priority": 7,
|
||||
"complexity": "complex",
|
||||
"estimated_hours": 12.0,
|
||||
"estimated_days": 1.5,
|
||||
},
|
||||
{
|
||||
"title": "Fix navigation bug",
|
||||
"description": "Mobile menu not closing properly",
|
||||
"workflow_type": "bugfix",
|
||||
"services": ["frontend"],
|
||||
"priority": 9,
|
||||
"complexity": "simple",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
batch_file = temp_dir / "batch.json"
|
||||
batch_file.write_text(json.dumps(batch_data, indent=2))
|
||||
return batch_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_batch_file(temp_dir: Path) -> Path:
|
||||
"""Create an empty batch JSON file."""
|
||||
batch_data = {"tasks": []}
|
||||
batch_file = temp_dir / "empty_batch.json"
|
||||
batch_file.write_text(json.dumps(batch_data))
|
||||
return batch_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_json_file(temp_dir: Path) -> Path:
|
||||
"""Create a file with invalid JSON."""
|
||||
batch_file = temp_dir / "invalid.json"
|
||||
batch_file.write_text("{ invalid json }")
|
||||
return batch_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_with_specs(temp_git_repo: Path) -> Path:
|
||||
"""Create a project with existing specs."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Spec 001 - with spec.md
|
||||
spec_001 = specs_dir / "001-existing-feature"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Existing Feature\n")
|
||||
(spec_001 / "requirements.json").write_text('{"task_description": "Existing"}')
|
||||
|
||||
# Spec 002 - with implementation plan
|
||||
spec_002 = specs_dir / "002-in-progress"
|
||||
spec_002.mkdir()
|
||||
(spec_002 / "spec.md").write_text("# In Progress\n")
|
||||
(spec_002 / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
# Spec 003 - complete with QA approval in implementation_plan.json
|
||||
spec_003 = specs_dir / "003-completed"
|
||||
spec_003.mkdir()
|
||||
(spec_003 / "spec.md").write_text("# Completed\n")
|
||||
(spec_003 / "implementation_plan.json").write_text(
|
||||
'{"phases": [], "qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
(spec_003 / "qa_report.md").write_text("# QA Approved\n")
|
||||
|
||||
return temp_git_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_with_completed_specs_and_worktrees(temp_git_repo: Path) -> Path:
|
||||
"""Create a project with completed specs and worktrees."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
|
||||
# Completed spec 001 with worktree (QA approved)
|
||||
spec_001 = specs_dir / "001-completed-with-wt"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "qa_report.md").write_text("# QA Approved\n")
|
||||
(spec_001 / "implementation_plan.json").write_text(
|
||||
'{"qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
|
||||
wt_001 = worktrees_dir / "001-completed-with-wt"
|
||||
wt_001.mkdir(parents=True)
|
||||
|
||||
# Completed spec 002 without worktree (QA approved)
|
||||
spec_002 = specs_dir / "002-completed-no-wt"
|
||||
spec_002.mkdir()
|
||||
(spec_002 / "qa_report.md").write_text("# QA Approved\n")
|
||||
(spec_002 / "implementation_plan.json").write_text(
|
||||
'{"qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
|
||||
# Incomplete spec 003
|
||||
spec_003 = specs_dir / "003-incomplete"
|
||||
spec_003.mkdir()
|
||||
(spec_003 / "spec.md").write_text("# In Progress\n")
|
||||
|
||||
return temp_git_repo
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_BATCH_CREATE_COMMAND TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleBatchCreateCommand:
|
||||
"""Tests for handle_batch_create_command() function."""
|
||||
|
||||
def test_creates_specs_from_batch_file(
|
||||
self, sample_batch_file: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Creates spec directories from batch file."""
|
||||
result = handle_batch_create_command(str(sample_batch_file), str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
assert specs_dir.exists()
|
||||
|
||||
# Should create 3 specs
|
||||
spec_dirs = sorted([d for d in specs_dir.iterdir() if d.is_dir()])
|
||||
assert len(spec_dirs) == 3
|
||||
|
||||
# Check spec numbering continues from 001
|
||||
assert spec_dirs[0].name == "001-add-user-authentication"
|
||||
assert spec_dirs[1].name == "002-add-payment-processing"
|
||||
assert spec_dirs[2].name == "003-fix-navigation-bug"
|
||||
|
||||
def test_creates_requirements_json(
|
||||
self, sample_batch_file: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Creates requirements.json with correct content."""
|
||||
handle_batch_create_command(str(sample_batch_file), str(temp_git_repo))
|
||||
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
spec_001 = specs_dir / "001-add-user-authentication"
|
||||
req_file = spec_001 / "requirements.json"
|
||||
|
||||
assert req_file.exists()
|
||||
|
||||
with open(req_file) as f:
|
||||
req = json.load(f)
|
||||
|
||||
assert req["task_description"] == "Implement OAuth2 login with Google provider"
|
||||
assert req["workflow_type"] == "feature"
|
||||
assert req["services_involved"] == ["backend", "frontend"]
|
||||
assert req["priority"] == 8
|
||||
assert req["complexity_inferred"] == "standard"
|
||||
assert req["estimate"]["estimated_hours"] == 6.0
|
||||
assert req["estimate"]["estimated_days"] == 0.75
|
||||
|
||||
def test_continues_numbering_from_existing_specs(
|
||||
self, project_with_specs: Path, sample_batch_file: Path
|
||||
) -> None:
|
||||
"""Continues spec numbering from existing specs."""
|
||||
handle_batch_create_command(str(sample_batch_file), str(project_with_specs))
|
||||
|
||||
specs_dir = project_with_specs / ".auto-claude" / "specs"
|
||||
spec_dirs = sorted([d for d in specs_dir.iterdir() if d.is_dir()])
|
||||
|
||||
# Should have existing 3 specs + 3 new ones
|
||||
assert len(spec_dirs) == 6
|
||||
|
||||
# New specs should start at 004
|
||||
assert spec_dirs[3].name == "004-add-user-authentication"
|
||||
assert spec_dirs[4].name == "005-add-payment-processing"
|
||||
assert spec_dirs[5].name == "006-fix-navigation-bug"
|
||||
|
||||
def test_returns_false_for_missing_file(self, temp_git_repo: Path) -> None:
|
||||
"""Returns False when batch file doesn't exist."""
|
||||
result = handle_batch_create_command(
|
||||
"nonexistent.json", str(temp_git_repo)
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_for_invalid_json(
|
||||
self, invalid_json_file: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Returns False for invalid JSON."""
|
||||
result = handle_batch_create_command(
|
||||
str(invalid_json_file), str(temp_git_repo)
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_for_empty_tasks(
|
||||
self, empty_batch_file: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Returns False when batch file has no tasks."""
|
||||
result = handle_batch_create_command(
|
||||
str(empty_batch_file), str(temp_git_repo)
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_sanitizes_task_title_for_folder_name(
|
||||
self, temp_dir: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Sanitizes task title when creating folder name."""
|
||||
batch_data = {
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Task With VERY Long Name That Should Be Truncated Because It Exceeds Fifty Characters",
|
||||
"description": "Test",
|
||||
}
|
||||
]
|
||||
}
|
||||
batch_file = temp_dir / "batch.json"
|
||||
batch_file.write_text(json.dumps(batch_data))
|
||||
|
||||
handle_batch_create_command(str(batch_file), str(temp_git_repo))
|
||||
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
spec_dirs = list(specs_dir.iterdir())
|
||||
|
||||
assert len(spec_dirs) == 1
|
||||
# Name should be truncated to 50 chars
|
||||
assert len(spec_dirs[0].name) <= 59 # "001-" + 50 chars
|
||||
assert spec_dirs[0].name.startswith("001-")
|
||||
|
||||
def test_uses_defaults_for_missing_fields(
|
||||
self, temp_dir: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Uses default values for missing optional fields."""
|
||||
batch_data = {
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Minimal Task",
|
||||
}
|
||||
]
|
||||
}
|
||||
batch_file = temp_dir / "batch.json"
|
||||
batch_file.write_text(json.dumps(batch_data))
|
||||
|
||||
handle_batch_create_command(str(batch_file), str(temp_git_repo))
|
||||
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
req_file = specs_dir / "001-minimal-task" / "requirements.json"
|
||||
|
||||
with open(req_file) as f:
|
||||
req = json.load(f)
|
||||
|
||||
assert req["task_description"] == "Minimal Task"
|
||||
assert req["workflow_type"] == "feature"
|
||||
assert req["services_involved"] == ["frontend"]
|
||||
assert req["priority"] == 5
|
||||
assert req["complexity_inferred"] == "standard"
|
||||
assert req["estimate"]["estimated_hours"] == 4.0
|
||||
assert req["estimate"]["estimated_days"] == 0.5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_BATCH_STATUS_COMMAND TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleBatchStatusCommand:
|
||||
"""Tests for handle_batch_status_command() function."""
|
||||
|
||||
def test_shows_status_for_all_specs(
|
||||
self, capsys, project_with_specs: Path
|
||||
) -> None:
|
||||
"""Shows status for all specs in project."""
|
||||
result = handle_batch_status_command(str(project_with_specs))
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 spec" in captured.out
|
||||
assert "001-existing-feature" in captured.out
|
||||
assert "002-in-progress" in captured.out
|
||||
assert "003-completed" in captured.out
|
||||
|
||||
def test_shows_correct_status_icons(
|
||||
self, capsys, project_with_specs: Path
|
||||
) -> None:
|
||||
"""Shows appropriate status icons for each spec."""
|
||||
handle_batch_status_command(str(project_with_specs))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Status icons for different states:
|
||||
# 001: spec.md only → spec_created (📋)
|
||||
# 002: spec.md + implementation_plan.json → building (⚙️)
|
||||
# 003: qa_report.md → qa_approved (✅)
|
||||
assert "📋" in captured.out
|
||||
assert "⚙️" in captured.out
|
||||
assert "✅" in captured.out
|
||||
|
||||
def test_returns_true_for_no_specs_directory(
|
||||
self, capsys, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Returns True when no specs directory exists."""
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No specs found" in captured.out
|
||||
|
||||
def test_returns_true_for_empty_specs_directory(
|
||||
self, capsys, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Returns True when specs directory is empty."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No specs found" in captured.out
|
||||
|
||||
def test_shows_task_description(
|
||||
self, capsys, project_with_specs: Path
|
||||
) -> None:
|
||||
"""Shows task description from requirements.json."""
|
||||
handle_batch_status_command(str(project_with_specs))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Existing" in captured.out
|
||||
|
||||
def test_detects_spec_created_status(
|
||||
self, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Correctly detects specs with spec.md as 'spec_created'."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Test\n")
|
||||
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_detects_building_status(
|
||||
self, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Correctly detects specs with implementation_plan.json as 'building'."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_detects_qa_approved_status(
|
||||
self, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Correctly detects specs with qa_signoff as 'qa_approved'."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "qa_report.md").write_text("# QA Approved\n")
|
||||
(spec_001 / "implementation_plan.json").write_text(
|
||||
'{"qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_detects_pending_spec_status(
|
||||
self, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Correctly detects specs with only requirements.json as 'pending_spec'."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "requirements.json").write_text('{"task": "test"}')
|
||||
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_handles_corrupted_requirements_json(
|
||||
self, capsys, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Handles corrupted requirements.json gracefully."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "requirements.json").write_text("{ invalid json")
|
||||
|
||||
result = handle_batch_status_command(str(temp_git_repo))
|
||||
|
||||
assert result is True
|
||||
captured = capsys.readouterr()
|
||||
assert "001-test" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_BATCH_CLEANUP_COMMAND TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleBatchCleanupCommand:
|
||||
"""Tests for handle_batch_cleanup_command() function."""
|
||||
|
||||
def test_dry_run_shows_what_would_be_deleted(
|
||||
self, capsys, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Dry run shows what would be deleted without actually deleting."""
|
||||
result = handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=True
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "2 completed spec" in captured.out
|
||||
assert "001-completed-with-wt" in captured.out
|
||||
assert "002-completed-no-wt" in captured.out
|
||||
assert "Would remove:" in captured.out
|
||||
assert "Run with --no-dry-run" in captured.out
|
||||
|
||||
def test_dry_run_does_not_delete(
|
||||
self, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Dry run does not actually delete anything."""
|
||||
specs_dir = project_with_completed_specs_and_worktrees / ".auto-claude" / "specs"
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=True
|
||||
)
|
||||
|
||||
# Specs should still exist
|
||||
assert (specs_dir / "001-completed-with-wt").exists()
|
||||
assert (specs_dir / "002-completed-no-wt").exists()
|
||||
|
||||
def test_cleanup_deletes_specs_and_worktrees(
|
||||
self, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Actually deletes completed specs and worktrees when dry_run=False."""
|
||||
specs_dir = project_with_completed_specs_and_worktrees / ".auto-claude" / "specs"
|
||||
worktrees_dir = project_with_completed_specs_and_worktrees / ".auto-claude" / "worktrees" / "tasks"
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=False
|
||||
)
|
||||
|
||||
# Completed specs should be deleted
|
||||
assert not (specs_dir / "001-completed-with-wt").exists()
|
||||
assert not (specs_dir / "002-completed-no-wt").exists()
|
||||
|
||||
# Worktree should be deleted
|
||||
assert not (worktrees_dir / "001-completed-with-wt").exists()
|
||||
|
||||
def test_cleanup_preserves_incomplete_specs(
|
||||
self, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Does not delete specs without qa_report.md."""
|
||||
specs_dir = project_with_completed_specs_and_worktrees / ".auto-claude" / "specs"
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=False
|
||||
)
|
||||
|
||||
# Incomplete spec should still exist
|
||||
assert (specs_dir / "003-incomplete").exists()
|
||||
|
||||
def test_returns_true_for_no_specs_directory(
|
||||
self, capsys, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Returns True when no specs directory exists."""
|
||||
result = handle_batch_cleanup_command(str(temp_git_repo), dry_run=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No specs directory found" in captured.out
|
||||
|
||||
def test_returns_true_for_no_completed_specs(
|
||||
self, capsys, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Returns True when no completed specs exist."""
|
||||
# Create specs without qa_report.md
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-incomplete"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# In Progress\n")
|
||||
|
||||
result = handle_batch_cleanup_command(str(temp_git_repo), dry_run=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No completed specs to clean up" in captured.out
|
||||
|
||||
def test_cleanup_with_git_worktree_remove(
|
||||
self, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Uses git worktree remove when available."""
|
||||
with patch('subprocess.run') as mock_run:
|
||||
# Mock git worktree remove to succeed
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=False
|
||||
)
|
||||
|
||||
# Should have called git worktree remove
|
||||
# Check that the first argument of any call contains "git", "worktree", "remove"
|
||||
assert any(
|
||||
"git" in str(call.args) and
|
||||
"worktree" in str(call.args) and
|
||||
"remove" in str(call.args)
|
||||
for call in mock_run.call_args_list
|
||||
)
|
||||
|
||||
def test_cleanup_fallback_to_manual_removal(
|
||||
self, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Falls back to manual removal when git worktree remove fails."""
|
||||
specs_dir = project_with_completed_specs_and_worktrees / ".auto-claude" / "specs"
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
# Mock git worktree remove to fail
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=False
|
||||
)
|
||||
|
||||
# Should still delete the spec
|
||||
assert not (specs_dir / "001-completed-with-wt").exists()
|
||||
|
||||
def test_cleanup_handles_timeout_gracefully(
|
||||
self, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Handles git command timeout gracefully."""
|
||||
specs_dir = project_with_completed_specs_and_worktrees / ".auto-claude" / "specs"
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
# Mock timeout
|
||||
from subprocess import TimeoutExpired
|
||||
mock_run.side_effect = TimeoutExpired("git", 30)
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=False
|
||||
)
|
||||
|
||||
# Should still delete the spec (fallback)
|
||||
assert not (specs_dir / "001-completed-with-wt").exists()
|
||||
|
||||
def test_cleanup_handles_exceptions(
|
||||
self, capsys, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Handles exceptions during cleanup gracefully."""
|
||||
with patch('subprocess.run') as mock_run:
|
||||
# Mock exception
|
||||
mock_run.side_effect = Exception("Test error")
|
||||
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=False
|
||||
)
|
||||
|
||||
# Should continue and delete specs
|
||||
captured = capsys.readouterr()
|
||||
assert "Cleaned up" in captured.out
|
||||
|
||||
def test_cleanup_shows_worktree_path_in_dry_run(
|
||||
self, capsys, project_with_completed_specs_and_worktrees: Path
|
||||
) -> None:
|
||||
"""Shows worktree path in dry run output."""
|
||||
handle_batch_cleanup_command(
|
||||
str(project_with_completed_specs_and_worktrees), dry_run=True
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert ".auto-claude/worktrees/tasks/001-completed-with-wt" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTEGRATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestBatchCommandsIntegration:
|
||||
"""Integration tests for batch commands."""
|
||||
|
||||
def test_create_then_status_workflow(
|
||||
self, sample_batch_file: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Test creating specs then checking status."""
|
||||
# Create specs
|
||||
create_result = handle_batch_create_command(
|
||||
str(sample_batch_file), str(temp_git_repo)
|
||||
)
|
||||
assert create_result is True
|
||||
|
||||
# Check status
|
||||
status_result = handle_batch_status_command(str(temp_git_repo))
|
||||
assert status_result is True
|
||||
|
||||
def test_create_then_cleanup_workflow(
|
||||
self, temp_dir: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Test creating specs, marking complete, then cleanup."""
|
||||
# Create a spec
|
||||
batch_data = {"tasks": [{"title": "Test Task"}]}
|
||||
batch_file = temp_dir / "batch.json"
|
||||
batch_file.write_text(json.dumps(batch_data))
|
||||
|
||||
handle_batch_create_command(str(batch_file), str(temp_git_repo))
|
||||
|
||||
# Mark as complete with proper QA approval
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
spec_001 = specs_dir / "001-test-task"
|
||||
(spec_001 / "qa_report.md").write_text("# QA Approved\n")
|
||||
(spec_001 / "implementation_plan.json").write_text(
|
||||
'{"qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
|
||||
# Dry run cleanup
|
||||
result = handle_batch_cleanup_command(str(temp_git_repo), dry_run=True)
|
||||
assert result is True
|
||||
|
||||
# Actual cleanup
|
||||
result = handle_batch_cleanup_command(str(temp_git_repo), dry_run=False)
|
||||
assert result is True
|
||||
|
||||
# Spec should be deleted
|
||||
assert not spec_001.exists()
|
||||
|
||||
|
||||
class TestBatchCommandsExceptionCoverage:
|
||||
"""Tests for exception handling paths to increase coverage."""
|
||||
|
||||
def test_cleanup_with_permission_error(
|
||||
self, temp_dir: Path, temp_git_repo: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Test cleanup handles permission errors gracefully."""
|
||||
|
||||
# Create a completed spec with proper QA approval
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
spec_001 = specs_dir / "001-test-task"
|
||||
spec_001.mkdir(parents=True)
|
||||
(spec_001 / "qa_report.md").write_text("# QA Approved\n")
|
||||
(spec_001 / "implementation_plan.json").write_text(
|
||||
'{"qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
|
||||
# Mock shutil.rmtree to raise permission error
|
||||
def mock_rmtree_raises(path, *args, **kwargs):
|
||||
if "001-test-task" in str(path):
|
||||
raise PermissionError(f"Permission denied: {path}")
|
||||
|
||||
monkeypatch.setattr("cli.batch_commands.shutil.rmtree", mock_rmtree_raises)
|
||||
|
||||
# Should handle the error gracefully and not crash
|
||||
result = handle_batch_cleanup_command(str(temp_git_repo), dry_run=False)
|
||||
assert result is True
|
||||
|
||||
def test_cleanup_with_generic_exception(
|
||||
self, temp_dir: Path, temp_git_repo: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Test cleanup handles generic exceptions gracefully."""
|
||||
|
||||
# Create a completed spec with proper QA approval
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
spec_001 = specs_dir / "001-test-task"
|
||||
spec_001.mkdir(parents=True)
|
||||
(spec_001 / "qa_report.md").write_text("# QA Approved\n")
|
||||
(spec_001 / "implementation_plan.json").write_text(
|
||||
'{"qa_signoff": {"status": "approved"}}'
|
||||
)
|
||||
|
||||
# Mock shutil.rmtree to raise generic exception
|
||||
def mock_rmtree_raises(path, *args, **kwargs):
|
||||
if "001-test-task" in str(path):
|
||||
raise RuntimeError(f"Cannot delete: {path}")
|
||||
|
||||
monkeypatch.setattr("cli.batch_commands.shutil.rmtree", mock_rmtree_raises)
|
||||
|
||||
# Should handle the error gracefully and not crash
|
||||
result = handle_batch_cleanup_command(str(temp_git_repo), dry_run=False)
|
||||
assert result is True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,970 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Followup Commands (cli/followup_commands.py)
|
||||
===========================================================
|
||||
|
||||
Tests for follow-up task commands:
|
||||
- collect_followup_task()
|
||||
- handle_followup_command()
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Note: conftest.py handles apps/backend path
|
||||
# Add tests directory to path for test_utils import (conftest doesn't handle this)
|
||||
if str(Path(__file__).parent) not in sys.path:
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mock external dependencies before importing cli.followup_commands
|
||||
# =============================================================================
|
||||
|
||||
# Import shared helper for creating mock modules
|
||||
from test_utils import _create_mock_module
|
||||
|
||||
# Mock modules
|
||||
if 'progress' not in sys.modules:
|
||||
sys.modules['progress'] = _create_mock_module()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auto-use fixture to set up mock UI module before importing cli.followup_commands
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_mock_ui_for_followup(mock_ui_module_full):
|
||||
"""Auto-use fixture that replaces sys.modules['ui'] with mock for each test."""
|
||||
sys.modules['ui'] = mock_ui_module_full
|
||||
yield
|
||||
|
||||
# =============================================================================
|
||||
# Import cli.followup_commands after mocking dependencies
|
||||
# =============================================================================
|
||||
|
||||
from cli.followup_commands import (
|
||||
collect_followup_task,
|
||||
handle_followup_command,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for collect_followup_task()
|
||||
# =============================================================================
|
||||
|
||||
class TestCollectFollowupTask:
|
||||
"""Tests for collect_followup_task() function."""
|
||||
|
||||
def test_returns_task_description_on_type(self, temp_dir, capsys):
|
||||
"""Returns task description when user chooses to type."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=['First line', 'Second line', '']):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is not None
|
||||
assert "First line" in result
|
||||
assert "Second line" in result
|
||||
|
||||
# Check that FOLLOWUP_REQUEST.md was created
|
||||
followup_file = spec_dir / "FOLLOWUP_REQUEST.md"
|
||||
assert followup_file.exists()
|
||||
assert followup_file.read_text() == result
|
||||
|
||||
def test_reads_from_file_when_selected(self, temp_dir, capsys):
|
||||
"""Reads task description from file when file option selected."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create a temp file with task description
|
||||
task_file = temp_dir / "task.txt"
|
||||
task_file.write_text("Task from file\nMultiple lines")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='file'):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is not None
|
||||
assert "Task from file" in result
|
||||
assert "Multiple lines" in result
|
||||
|
||||
def test_handles_nonexistent_file(self, temp_dir, capsys):
|
||||
"""Handles case when specified file doesn't exist."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='file'):
|
||||
with patch('builtins.input', return_value='/nonexistent/file.txt'):
|
||||
with patch('cli.followup_commands.select_menu', return_value='quit'):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_handles_empty_file(self, temp_dir, capsys):
|
||||
"""Handles case when file is empty."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create empty file
|
||||
task_file = temp_dir / "empty.txt"
|
||||
task_file.write_text("")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', side_effect=[str(task_file)]):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_handles_permission_error(self, temp_dir, capsys):
|
||||
"""Handles permission denied error when reading file."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
task_file = temp_dir / "restricted.txt"
|
||||
task_file.write_text("Content")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
# Mock Path.read_text to raise PermissionError
|
||||
with patch('pathlib.Path.read_text', side_effect=PermissionError("Denied")):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_quit(self, temp_dir):
|
||||
"""Returns None when user selects quit."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='quit'):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_retries_on_empty_input(self, temp_dir, capsys):
|
||||
"""Retries when user provides empty input."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# First attempt: type with empty input
|
||||
# Second attempt: type with actual content
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['type', 'type']):
|
||||
with patch('builtins.input', side_effect=[
|
||||
'', # First attempt - empty
|
||||
'Actual task content', # Second attempt - content
|
||||
''
|
||||
]):
|
||||
result = collect_followup_task(spec_dir, max_retries=3)
|
||||
|
||||
assert result is not None
|
||||
assert "Actual task content" in result
|
||||
|
||||
def test_respects_max_retries(self, temp_dir, capsys):
|
||||
"""Stops retrying after max attempts reached."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Always return empty input
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=['', '', '', '']):
|
||||
result = collect_followup_task(spec_dir, max_retries=2)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Maximum retry" in captured.out or "cancelled" in captured.out.lower()
|
||||
|
||||
def test_handles_keyboard_interrupt(self, temp_dir, capsys):
|
||||
"""Handles KeyboardInterrupt during input collection."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_handles_eof_error(self, temp_dir, capsys):
|
||||
"""Handles EOFError during input collection."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=EOFError):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
# EOFError should break the input loop, returning None if empty
|
||||
# The actual content would be empty, so it should retry or return None
|
||||
assert result is None
|
||||
|
||||
def test_saves_to_followup_request_file(self, temp_dir):
|
||||
"""Saves the collected task to FOLLOWUP_REQUEST.md."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
task_description = "This is a test follow-up task"
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=[task_description, '']):
|
||||
collect_followup_task(spec_dir)
|
||||
|
||||
followup_file = spec_dir / "FOLLOWUP_REQUEST.md"
|
||||
assert followup_file.exists()
|
||||
assert followup_file.read_text() == task_description
|
||||
|
||||
def test_handles_empty_file_path(self, temp_dir, capsys):
|
||||
"""Handles case when no file path is provided."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=''):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "No file path" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_expands_tilde_in_path(self, temp_dir):
|
||||
"""Expands ~ in file path to home directory."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create a file in temp_dir to simulate home
|
||||
task_file = temp_dir / "task.txt"
|
||||
task_file.write_text("Task content")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='file'):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
with patch('pathlib.Path.expanduser', return_value=task_file):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is not None
|
||||
assert "Task content" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for handle_followup_command()
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleFollowupCommand:
|
||||
"""Tests for handle_followup_command() function."""
|
||||
|
||||
@patch('cli.utils.validate_environment')
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('progress.is_build_complete')
|
||||
@patch('progress.count_subtasks')
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_exits_when_no_implementation_plan(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_count,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Exits with error when implementation plan doesn't exist."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# sys.exit is called directly in the function, so we need to catch SystemExit
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No implementation plan found" in captured.out or "not been built" in captured.out
|
||||
|
||||
@patch('cli.utils.validate_environment')
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete')
|
||||
@patch('cli.followup_commands.count_subtasks')
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_exits_when_build_not_complete(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_count,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Exits with error when build is not complete."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{}')
|
||||
|
||||
mock_is_complete.return_value = False
|
||||
mock_count.return_value = (2, 5) # 2 completed, 5 total
|
||||
|
||||
# sys.exit is called directly in the function
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "not complete" in captured.out or "pending" in captured.out
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_runs_planner_after_collecting_task(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Runs follow-up planner after successfully collecting task."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Add new feature"
|
||||
mock_run_planner.return_value = True
|
||||
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
assert mock_run_planner.called
|
||||
call_kwargs = mock_run_planner.call_args[1]
|
||||
assert call_kwargs['project_dir'] == temp_dir
|
||||
assert call_kwargs['spec_dir'] == spec_dir
|
||||
assert call_kwargs['model'] == "sonnet"
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_returns_when_user_cancels(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Returns early when user cancels task collection."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = None
|
||||
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
assert not mock_run_planner.called
|
||||
captured = capsys.readouterr()
|
||||
assert "cancel" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=False)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_exits_when_environment_invalid(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir
|
||||
):
|
||||
"""Exits when environment validation fails."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Task description"
|
||||
|
||||
# sys.exit is called directly in the function
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert not mock_run_planner.called
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_successful_planning(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Shows success message when planning completes successfully."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Add feature"
|
||||
mock_run_planner.return_value = True
|
||||
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "COMPLETE" in captured.out or "success" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_planning_failure(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Shows warning when planning doesn't fully succeed."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Add feature"
|
||||
mock_run_planner.return_value = False
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "INCOMPLETE" in captured.out or "warning" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_keyboard_interrupt(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Handles KeyboardInterrupt during planning."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Add feature"
|
||||
mock_run_planner.side_effect = KeyboardInterrupt()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "paused" in captured.out.lower() or "retry" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_planning_exception(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Handles exception during planning."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Add feature"
|
||||
mock_run_planner.side_effect = Exception("Planning failed")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet", verbose=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "error" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_shows_traceback_in_verbose_mode(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Shows traceback in verbose mode."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = "Add feature"
|
||||
test_error = Exception("Test error")
|
||||
mock_run_planner.side_effect = test_error
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet", verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# In verbose mode, traceback should be printed
|
||||
assert "error" in captured.out.lower()
|
||||
|
||||
def test_counts_prior_followups(self, temp_dir, capsys):
|
||||
"""Counts and displays prior follow-up phases."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# Create implementation plan with follow-up phases
|
||||
plan = {
|
||||
"phases": [
|
||||
{"name": "Initial Phase"},
|
||||
{"name": "Follow-Up: Bug Fixes"},
|
||||
{"name": "Followup: Enhancement"},
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
with patch('cli.followup_commands.is_build_complete', return_value=True):
|
||||
with patch('cli.followup_commands.collect_followup_task', return_value=None):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should indicate prior follow-ups were detected
|
||||
# The exact output depends on the implementation
|
||||
assert "complete" in captured.out.lower()
|
||||
|
||||
def test_shows_ready_message_for_first_followup(self, temp_dir, capsys):
|
||||
"""Shows appropriate message for first follow-up."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# Create plan without follow-up phases
|
||||
plan = {"phases": [{"name": "Initial Phase"}]}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
with patch('cli.followup_commands.is_build_complete', return_value=True):
|
||||
with patch('cli.followup_commands.collect_followup_task', return_value=None):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "complete" in captured.out.lower() or "ready" in captured.out.lower()
|
||||
|
||||
def test_passes_verbose_flag_to_planner(self, temp_dir):
|
||||
"""Passes verbose flag to follow-up planner."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
with patch('cli.utils.validate_environment', return_value=True):
|
||||
with patch('agent.run_followup_planner', new_callable=AsyncMock, return_value=True) as mock_planner:
|
||||
with patch('cli.followup_commands.is_build_complete', return_value=True):
|
||||
with patch('cli.followup_commands.collect_followup_task', return_value="Task"):
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet", verbose=True)
|
||||
|
||||
call_kwargs = mock_planner.call_args[1]
|
||||
assert call_kwargs['verbose'] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Additional tests for improved coverage (lines 108-111, 139-144, 150-153, 296-297)
|
||||
# =============================================================================
|
||||
|
||||
def test_handles_keyboard_interrupt_on_file_path_input(self, temp_dir, capsys):
|
||||
"""Handles KeyboardInterrupt when entering file path (lines 108-111)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='file'):
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_handles_eof_error_on_file_path_input(self, temp_dir, capsys):
|
||||
"""Handles EOFError when entering file path (lines 108-111)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='file'):
|
||||
with patch('builtins.input', side_effect=EOFError):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_handles_file_not_found_error(self, temp_dir, capsys):
|
||||
"""Handles FileNotFoundError when file doesn't exist (lines 139-144)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create a path that doesn't exist
|
||||
nonexistent_file = temp_dir / "does_not_exist.txt"
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(nonexistent_file)):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
# Should show file not found error
|
||||
assert "not found" in captured.out.lower() or "check that the path" in captured.out.lower()
|
||||
|
||||
def test_handles_generic_exception_on_file_read(self, temp_dir, capsys):
|
||||
"""Handles generic exception when reading file (lines 150-153)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create a file that exists
|
||||
task_file = temp_dir / "task.txt"
|
||||
task_file.write_text("Content")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
# Mock read_text to raise a generic exception
|
||||
with patch('pathlib.Path.read_text', side_effect=OSError("Read error")):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "error" in captured.out.lower()
|
||||
|
||||
def test_handles_unicode_decode_error_on_file_read(self, temp_dir, capsys):
|
||||
"""Handles UnicodeDecodeError when reading file (lines 150-153)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create a file that exists
|
||||
task_file = temp_dir / "task.txt"
|
||||
task_file.write_text("Content")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
# Mock read_text to raise UnicodeDecodeError
|
||||
with patch('pathlib.Path.read_text', side_effect=UnicodeDecodeError('utf-8', b'', 0, 1, 'invalid')):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "error" in captured.out.lower()
|
||||
|
||||
def test_handles_runtime_error_on_file_read(self, temp_dir, capsys):
|
||||
"""Handles RuntimeError when reading file (lines 150-153)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create a file that exists
|
||||
task_file = temp_dir / "task.txt"
|
||||
task_file.write_text("Content")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
# Mock read_text to raise RuntimeError
|
||||
with patch('pathlib.Path.read_text', side_effect=RuntimeError("Unexpected error")):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "error" in captured.out.lower()
|
||||
|
||||
|
||||
class TestHandleFollowupCommandEdgeCases:
|
||||
"""Additional tests for handle_followup_command() edge cases (lines 296-297)."""
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_json_decode_error_in_plan_file(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Handles JSONDecodeError when implementation_plan.json is malformed (lines 296-297)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# Write invalid JSON to implementation_plan.json
|
||||
(spec_dir / "implementation_plan.json").write_text('{ invalid json }')
|
||||
|
||||
mock_collect.return_value = None
|
||||
|
||||
# Should handle the JSONDecodeError gracefully and continue
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should complete without error (prior_followup_count just stays 0)
|
||||
assert "complete" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_keyerror_in_plan_file(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Handles KeyError when implementation_plan.json is missing expected keys (lines 296-297)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# Write JSON without 'phases' key
|
||||
(spec_dir / "implementation_plan.json").write_text('{"other_key": "value"}')
|
||||
|
||||
mock_collect.return_value = None
|
||||
|
||||
# Should handle the missing key gracefully
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "complete" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_phase_with_missing_name_key(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Handles phase dict without 'name' key (lines 296-297)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# Write JSON with phase missing 'name' key
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": [{"other_key": "value"}, {"name": "Valid Phase"}]}')
|
||||
|
||||
mock_collect.return_value = None
|
||||
|
||||
# Should handle missing name gracefully (uses .get() with default)
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "complete" in captured.out.lower()
|
||||
|
||||
@patch('cli.utils.validate_environment', return_value=True)
|
||||
@patch('agent.run_followup_planner', new_callable=AsyncMock)
|
||||
@patch('cli.followup_commands.is_build_complete', return_value=True)
|
||||
@patch('cli.followup_commands.collect_followup_task')
|
||||
def test_handles_empty_phases_in_plan(
|
||||
self,
|
||||
mock_collect,
|
||||
mock_is_complete,
|
||||
mock_run_planner,
|
||||
mock_validate,
|
||||
temp_dir,
|
||||
capsys
|
||||
):
|
||||
"""Handles empty phases array in implementation plan (lines 296-297)."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "spec.md").write_text("# Test")
|
||||
|
||||
# Write JSON with empty phases array
|
||||
(spec_dir / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
mock_collect.return_value = None
|
||||
|
||||
handle_followup_command(temp_dir, spec_dir, "sonnet")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "complete" in captured.out.lower()
|
||||
|
||||
|
||||
|
||||
class TestCollectFollowupTaskEdgeCases:
|
||||
"""Additional edge case tests for collect_followup_task()."""
|
||||
|
||||
def test_handles_file_with_only_whitespace(self, temp_dir, capsys):
|
||||
"""Handles file that contains only whitespace characters."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create file with only whitespace
|
||||
task_file = temp_dir / "whitespace.txt"
|
||||
task_file.write_text(" \n\n\t\n ")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
# .strip() would make the content empty, triggering the empty file message
|
||||
assert "empty" in captured.out.lower() or "cancel" in captured.out.lower()
|
||||
|
||||
def test_handles_file_with_newline_only_content(self, temp_dir, capsys):
|
||||
"""Handles file that contains only newlines."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create file with only newlines
|
||||
task_file = temp_dir / "newlines.txt"
|
||||
task_file.write_text("\n\n\n")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_handles_file_read_with_os_error(self, temp_dir, capsys):
|
||||
"""Handles OSError when reading file."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
task_file = temp_dir / "task.txt"
|
||||
task_file.write_text("Content")
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value=str(task_file)):
|
||||
with patch('pathlib.Path.read_text', side_effect=OSError("OS error reading file")):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "error" in captured.out.lower()
|
||||
|
||||
def test_handles_value_error_on_file_path(self, temp_dir, capsys):
|
||||
"""Handles ValueError during file path resolution."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
with patch('cli.followup_commands.select_menu', side_effect=['file', 'quit']):
|
||||
with patch('builtins.input', return_value='/valid/path'):
|
||||
# Mock resolve to raise ValueError
|
||||
with patch('pathlib.Path.resolve', side_effect=ValueError("Invalid path")):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
# Should handle gracefully and return None or retry
|
||||
assert result is None
|
||||
|
||||
def test_handles_type_input_with_trailing_whitespace(self, temp_dir):
|
||||
"""Properly strips trailing whitespace from typed input."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
task_description = "Task content with trailing spaces "
|
||||
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=[task_description, '']):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is not None
|
||||
# Should be stripped
|
||||
assert result == "Task content with trailing spaces"
|
||||
|
||||
def test_handles_type_input_with_internal_whitespace(self, temp_dir):
|
||||
"""Preserves internal whitespace in typed input."""
|
||||
spec_dir = temp_dir / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Note: empty line terminates input, so we need non-empty lines only
|
||||
# Then a final empty line to signal completion
|
||||
with patch('cli.followup_commands.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=["Line 1", "Line 2", " Line 3", '']):
|
||||
result = collect_followup_task(spec_dir)
|
||||
|
||||
assert result is not None
|
||||
assert "Line 1" in result
|
||||
assert "Line 2" in result
|
||||
assert "Line 3" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS: Module-level path insertion (line 16)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFollowupCommandsModuleImport:
|
||||
"""Tests for covering module-level path insertion (line 16)."""
|
||||
|
||||
def test_module_import_executes_path_insertion(self):
|
||||
"""Module import executes sys.path.insert (line 16)."""
|
||||
# Get the module path and parent directory
|
||||
import cli.followup_commands as followup_module
|
||||
module_path = followup_module.__file__
|
||||
parent_dir = str(Path(module_path).parent.parent)
|
||||
|
||||
# Save original sys.path
|
||||
original_path = sys.path.copy()
|
||||
|
||||
# Remove the parent directory from sys.path to make the condition True
|
||||
while parent_dir in sys.path:
|
||||
sys.path.remove(parent_dir)
|
||||
|
||||
# Remove module and its submodules from sys.modules to force re-import
|
||||
modules_to_remove = [k for k in sys.modules.keys() if k.startswith('cli.followup_commands')]
|
||||
for mod_name in modules_to_remove:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
# Now import it fresh - this should execute line 16 under coverage
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("cli.followup_commands", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules['cli.followup_commands'] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Verify the module loaded correctly
|
||||
assert hasattr(module, 'handle_followup_command')
|
||||
|
||||
# Restore original sys.path
|
||||
sys.path[:] = original_path
|
||||
@@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Input Handlers (cli/input_handlers.py)
|
||||
====================================================
|
||||
|
||||
Tests for reusable user input collection utilities:
|
||||
- collect_user_input_interactive()
|
||||
- read_from_file()
|
||||
- read_multiline_input()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auto-use fixture to set up mock UI module before importing cli.input_handlers
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_mock_ui_for_input_handlers(mock_ui_module_full):
|
||||
"""Auto-use fixture that replaces sys.modules['ui'] with mock for each test."""
|
||||
sys.modules['ui'] = mock_ui_module_full
|
||||
yield
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Import cli.input_handlers - works because conftest.py pre-mocks ui module in sys.modules
|
||||
# The autouse fixture refreshes the mock before each test.
|
||||
# =============================================================================
|
||||
|
||||
from cli.input_handlers import (
|
||||
collect_user_input_interactive,
|
||||
read_from_file,
|
||||
read_multiline_input,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for collect_user_input_interactive()
|
||||
# =============================================================================
|
||||
|
||||
class TestCollectUserInputInteractive:
|
||||
"""Tests for collect_user_input_interactive() function."""
|
||||
|
||||
def test_returns_input_when_type_selected(self, capsys):
|
||||
"""Returns user input when type option is selected."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=['Line 1', 'Line 2', '']):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "Line 1" in result
|
||||
assert "Line 2" in result
|
||||
|
||||
def test_returns_input_when_paste_selected(self, capsys):
|
||||
"""Returns user input when paste option is selected."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='paste'):
|
||||
with patch('builtins.input', side_effect=['Pasted content', '']):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "Pasted content" in result
|
||||
|
||||
def test_reads_from_file_when_file_selected(self, temp_dir):
|
||||
"""Reads input from file when file option is selected."""
|
||||
# Create a test file
|
||||
test_file = temp_dir / "input.txt"
|
||||
test_file.write_text("Content from file")
|
||||
|
||||
with patch('cli.input_handlers.select_menu', return_value='file'):
|
||||
with patch('builtins.input', return_value=str(test_file)):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "Content from file" in result
|
||||
|
||||
def test_returns_empty_string_when_skip_selected(self):
|
||||
"""Returns empty string when skip option is selected."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='skip'):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_returns_none_when_quit_selected(self):
|
||||
"""Returns None when quit option is selected."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='quit'):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_menu_returns_none(self):
|
||||
"""Returns None when select_menu returns None."""
|
||||
with patch('cli.input_handlers.select_menu', return_value=None):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_hides_file_option_when_disabled(self):
|
||||
"""Does not show file option when allow_file is False."""
|
||||
with patch('cli.input_handlers.select_menu') as mock_menu:
|
||||
mock_menu.return_value = 'type'
|
||||
with patch('builtins.input', side_effect=['Test', '']):
|
||||
collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:",
|
||||
allow_file=False
|
||||
)
|
||||
|
||||
# Check that options were passed to select_menu
|
||||
options = mock_menu.call_args[1]['options']
|
||||
keys = [opt.key for opt in options]
|
||||
assert 'file' not in keys
|
||||
assert 'type' in keys
|
||||
assert 'skip' in keys
|
||||
assert 'quit' in keys
|
||||
|
||||
def test_hides_paste_option_when_disabled(self):
|
||||
"""Does not show paste option when allow_paste is False."""
|
||||
with patch('cli.input_handlers.select_menu') as mock_menu:
|
||||
mock_menu.return_value = 'type'
|
||||
with patch('builtins.input', side_effect=['Test', '']):
|
||||
collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:",
|
||||
allow_paste=False
|
||||
)
|
||||
|
||||
# Check that options were passed to select_menu
|
||||
options = mock_menu.call_args[1]['options']
|
||||
keys = [opt.key for opt in options]
|
||||
assert 'paste' not in keys
|
||||
assert 'type' in keys
|
||||
assert 'file' in keys
|
||||
|
||||
def test_passes_title_and_subtitle_to_menu(self):
|
||||
"""Passes title and subtitle to select_menu."""
|
||||
with patch('cli.input_handlers.select_menu') as mock_menu:
|
||||
mock_menu.return_value = 'skip'
|
||||
collect_user_input_interactive(
|
||||
title="Custom Title",
|
||||
subtitle="Custom Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert mock_menu.called
|
||||
call_kwargs = mock_menu.call_args[1]
|
||||
assert call_kwargs['title'] == "Custom Title"
|
||||
assert call_kwargs['subtitle'] == "Custom Subtitle"
|
||||
|
||||
def test_handles_keyboard_interrupt_during_type(self, capsys):
|
||||
"""Handles KeyboardInterrupt during type input."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_handles_eof_error_during_type(self, capsys):
|
||||
"""Handles EOFError during type input."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=EOFError):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
# EOFError should break the input loop
|
||||
# Result could be empty string or None depending on implementation
|
||||
assert result is None or result == ""
|
||||
|
||||
def test_file_read_failure_returns_none(self, temp_dir):
|
||||
"""Returns None when file read fails."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='file'):
|
||||
with patch('builtins.input', return_value='/nonexistent/file.txt'):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_strips_whitespace_from_input(self):
|
||||
"""Strips leading/trailing whitespace from collected input."""
|
||||
with patch('cli.input_handlers.select_menu', return_value='type'):
|
||||
with patch('builtins.input', side_effect=[' Text with spaces ', '']):
|
||||
result = collect_user_input_interactive(
|
||||
title="Test Title",
|
||||
subtitle="Test Subtitle",
|
||||
prompt_text="Enter your input:"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.strip() == result
|
||||
assert not result.startswith(" ")
|
||||
assert not result.endswith(" ")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for read_from_file()
|
||||
# =============================================================================
|
||||
|
||||
class TestReadFromFile:
|
||||
"""Tests for read_from_file() function."""
|
||||
|
||||
def test_returns_file_contents(self, temp_dir, capsys):
|
||||
"""Returns contents of the specified file."""
|
||||
test_file = temp_dir / "test.txt"
|
||||
test_file.write_text("File content here")
|
||||
|
||||
with patch('builtins.input', return_value=str(test_file)):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is not None
|
||||
assert result == "File content here"
|
||||
|
||||
def test_returns_none_when_no_path_provided(self, capsys):
|
||||
"""Returns None when no file path is provided."""
|
||||
with patch('builtins.input', return_value=''):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "No file path" in captured.out
|
||||
|
||||
def test_returns_none_for_nonexistent_file(self, capsys):
|
||||
"""Returns None when file doesn't exist."""
|
||||
with patch('builtins.input', return_value='/nonexistent/path.txt'):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
# The error message could be "not found" or "Permission denied" depending on the system
|
||||
assert "not found" in captured.out.lower() or "no such file" in captured.out.lower() or "permission denied" in captured.out.lower() or "cannot read" in captured.out.lower()
|
||||
|
||||
def test_returns_none_for_empty_file(self, temp_dir, capsys):
|
||||
"""Returns None when file is empty."""
|
||||
empty_file = temp_dir / "empty.txt"
|
||||
empty_file.write_text("")
|
||||
|
||||
with patch('builtins.input', return_value=str(empty_file)):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "empty" in captured.out.lower()
|
||||
|
||||
def test_returns_none_on_permission_error(self, temp_dir, capsys):
|
||||
"""Returns None when file cannot be read due to permissions."""
|
||||
# Create a real temporary file
|
||||
restricted_file = temp_dir / "restricted.txt"
|
||||
restricted_file.write_text("secret content")
|
||||
|
||||
with patch('builtins.input', return_value=str(restricted_file)):
|
||||
with patch.object(Path, 'read_text', side_effect=PermissionError("Denied")):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Permission" in captured.out or "denied" in captured.out.lower()
|
||||
|
||||
def test_returns_none_on_keyboard_interrupt(self, capsys):
|
||||
"""Returns None when user interrupts input."""
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_returns_none_on_eof_error(self, capsys):
|
||||
"""Returns None on EOFError during input."""
|
||||
with patch('builtins.input', side_effect=EOFError):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_expands_tilde_in_path(self, temp_dir):
|
||||
"""Expands ~ to home directory in file path."""
|
||||
test_file = temp_dir / "test.txt"
|
||||
test_file.write_text("Content")
|
||||
|
||||
with patch('builtins.input', return_value='~/test.txt'):
|
||||
with patch('pathlib.Path.expanduser', return_value=test_file):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is not None
|
||||
assert result == "Content"
|
||||
|
||||
def test_resolves_relative_paths(self, temp_dir):
|
||||
"""Resolves relative file paths to absolute."""
|
||||
test_file = temp_dir / "subdir" / "test.txt"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_text("Resolved content")
|
||||
|
||||
# Change to temp_dir
|
||||
import os
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(temp_dir)
|
||||
with patch('builtins.input', return_value='subdir/test.txt'):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is not None
|
||||
assert result == "Resolved content"
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_shows_character_count(self, temp_dir, capsys):
|
||||
"""Shows number of characters loaded from file."""
|
||||
test_file = temp_dir / "test.txt"
|
||||
content = "A" * 100
|
||||
test_file.write_text(content)
|
||||
|
||||
with patch('builtins.input', return_value=str(test_file)):
|
||||
result = read_from_file()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "100" in captured.out or "character" in captured.out.lower()
|
||||
|
||||
def test_handles_unicode_content(self, temp_dir):
|
||||
"""Handles files with Unicode content."""
|
||||
test_file = temp_dir / "unicode.txt"
|
||||
content = "Hello 世界 🌍 Привет"
|
||||
test_file.write_text(content, encoding='utf-8')
|
||||
|
||||
with patch('builtins.input', return_value=str(test_file)):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is not None
|
||||
assert result == content
|
||||
|
||||
def test_strips_whitespace_from_file_content(self, temp_dir):
|
||||
"""Strips leading/trailing whitespace from file content."""
|
||||
test_file = temp_dir / "spaces.txt"
|
||||
test_file.write_text(" Content with spaces ")
|
||||
|
||||
with patch('builtins.input', return_value=str(test_file)):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is not None
|
||||
assert result == "Content with spaces"
|
||||
assert not result.startswith(" ")
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_handles_generic_exception(self, temp_dir, capsys):
|
||||
"""Handles generic exceptions during file reading."""
|
||||
# Create a real temporary file
|
||||
test_file = temp_dir / "error_file.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
with patch('builtins.input', return_value=str(test_file)):
|
||||
with patch.object(Path, 'read_text', side_effect=Exception("Unknown error")):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Error" in captured.out or "error" in captured.out.lower()
|
||||
|
||||
def test_file_not_found_after_resolve(self, temp_dir, capsys):
|
||||
"""Returns None when path resolves but file doesn't exist (lines 163-164)."""
|
||||
# Use a path in a valid temp directory but the file doesn't exist
|
||||
nonexistent_file = temp_dir / "does_not_exist.txt"
|
||||
|
||||
with patch('builtins.input', return_value=str(nonexistent_file)):
|
||||
result = read_from_file()
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
# Should show "File not found" error message
|
||||
assert "not found" in captured.out.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for read_multiline_input()
|
||||
# =============================================================================
|
||||
|
||||
class TestReadMultilineInput:
|
||||
"""Tests for read_multiline_input() function."""
|
||||
|
||||
def test_returns_single_line_input(self):
|
||||
"""Returns single line of input."""
|
||||
with patch('builtins.input', side_effect=['Single line', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert result == "Single line"
|
||||
|
||||
def test_returns_multiple_lines_of_input(self):
|
||||
"""Returns multiple lines joined by newline."""
|
||||
with patch('builtins.input', side_effect=['Line 1', 'Line 2', 'Line 3', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert result == "Line 1\nLine 2\nLine 3"
|
||||
|
||||
def test_stops_on_empty_line(self):
|
||||
"""Stops reading when encountering an empty line."""
|
||||
with patch('builtins.input', side_effect=['Line 1', 'Line 2', '', 'Should not be included']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert "Should not be included" not in result
|
||||
|
||||
def test_returns_none_on_keyboard_interrupt(self, capsys):
|
||||
"""Returns None when user interrupts with Ctrl+C."""
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is None
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out or "cancel" in captured.out.lower()
|
||||
|
||||
def test_breaks_on_eof_error(self):
|
||||
"""Breaks input loop on EOFError."""
|
||||
with patch('builtins.input', side_effect=['Line 1', EOFError]):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
# Should return content before EOF
|
||||
assert result is not None
|
||||
assert "Line 1" in result
|
||||
|
||||
def test_handles_empty_input(self):
|
||||
"""Handles case where user enters nothing."""
|
||||
with patch('builtins.input', side_effect=['', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_strips_whitespace_from_result(self):
|
||||
"""Strips leading/trailing whitespace from final result."""
|
||||
with patch('builtins.input', side_effect=[' Line 1 ', ' Line 2 ', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
# Note: The implementation strips each line but not the overall result
|
||||
# Behavior depends on implementation
|
||||
assert result is not None
|
||||
assert "Line 1" in result
|
||||
|
||||
def test_handles_unicode_input(self):
|
||||
"""Handles Unicode characters in input."""
|
||||
with patch('builtins.input', side_effect=['Hello 世界', '🌍 Emoji', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert "世界" in result
|
||||
assert "🌍" in result
|
||||
|
||||
def test_preserves_internal_whitespace(self):
|
||||
"""Preserves internal whitespace in lines."""
|
||||
with patch('builtins.input', side_effect=['Line with spaces', 'Line\twith\ttabs', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert " " in result
|
||||
assert "\t" in result
|
||||
|
||||
def test_passes_prompt_text_to_box(self, capsys):
|
||||
"""Passes prompt text to the box display."""
|
||||
custom_prompt = "Custom prompt text"
|
||||
with patch('builtins.input', side_effect=['', '']):
|
||||
read_multiline_input(custom_prompt)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# The actual custom prompt text should appear in the output
|
||||
assert custom_prompt.lower() in captured.out.lower()
|
||||
|
||||
def test_allows_multiple_consecutive_empty_lines_to_stop(self):
|
||||
"""Stops on first empty line (empty_count >= 1)."""
|
||||
with patch('builtins.input', side_effect=['Line 1', '', '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert result == "Line 1"
|
||||
|
||||
def test_handles_long_lines(self):
|
||||
"""Handles very long input lines."""
|
||||
long_line = "A" * 10000
|
||||
with patch('builtins.input', side_effect=[long_line, '']):
|
||||
result = read_multiline_input("Enter text:")
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 10000
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for module import behavior (line 14 - sys.path insertion)
|
||||
# =============================================================================
|
||||
|
||||
class TestModuleImportPathInsertion:
|
||||
"""Tests for module-level path manipulation logic."""
|
||||
|
||||
def test_inserts_parent_dir_to_sys_path_when_not_present(self):
|
||||
"""
|
||||
Test that line 14 executes: sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
This test covers the scenario where _PARENT_DIR is not in sys.path
|
||||
when the module-level code executes.
|
||||
|
||||
Note: This test manually executes the module-level code that would
|
||||
normally run on import, since we can't easily re-import after removing
|
||||
the path (the module wouldn't be found without the path).
|
||||
"""
|
||||
from cli.input_handlers import _PARENT_DIR
|
||||
|
||||
# Get the parent dir that should be inserted by line 14
|
||||
parent_dir_str = str(_PARENT_DIR)
|
||||
parent_dir_normalized = os.path.normpath(parent_dir_str)
|
||||
|
||||
# Verify parent_dir_str is the apps/backend directory (cross-platform)
|
||||
expected_suffix = os.path.join("apps", "backend")
|
||||
assert parent_dir_normalized.endswith(expected_suffix) or parent_dir_str.endswith("apps/backend")
|
||||
|
||||
# Save current sys.path state to restore later
|
||||
original_path = sys.path.copy()
|
||||
|
||||
# Remove the parent dir from sys.path to simulate the condition on line 13
|
||||
# Use normalized paths for comparison to handle different path separators
|
||||
paths_to_restore = []
|
||||
for p in sys.path[:]: # Copy to avoid modification during iteration
|
||||
p_normalized = os.path.normpath(p)
|
||||
if expected_suffix in p_normalized or p == parent_dir_str:
|
||||
paths_to_restore.append(p)
|
||||
sys.path.remove(p)
|
||||
|
||||
try:
|
||||
# Verify parent_dir_str is NOT in sys.path now
|
||||
assert parent_dir_str not in sys.path
|
||||
|
||||
# Now manually execute the logic from lines 13-14 of input_handlers.py
|
||||
# This simulates what happens when the module is imported without the path
|
||||
# We use the _PARENT_DIR value that was already imported
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
# This is line 14 - the line we're testing
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
# Verify the parent dir was added to sys.path at position 0
|
||||
assert parent_dir_str in sys.path, f"Parent dir {parent_dir_str} should be in sys.path"
|
||||
assert sys.path[0] == parent_dir_str, f"Parent dir should be at sys.path[0]"
|
||||
|
||||
finally:
|
||||
# Restore sys.path to original state
|
||||
sys.path[:] = original_path
|
||||
|
||||
def test_line_14_coverage_via_importlib_reload(self):
|
||||
"""
|
||||
Test that line 14 executes using importlib.reload() with path manipulation.
|
||||
|
||||
This test forces a reload of the module in a state where _PARENT_DIR
|
||||
is not in sys.path, triggering line 14 execution.
|
||||
"""
|
||||
import importlib
|
||||
import cli.input_handlers
|
||||
|
||||
# Get the parent dir that should be inserted by line 14
|
||||
parent_dir_str = str(cli.input_handlers._PARENT_DIR)
|
||||
|
||||
# Save current sys.path and sys.modules state to restore later
|
||||
original_path = sys.path.copy()
|
||||
original_module = sys.modules.get('cli.input_handlers')
|
||||
|
||||
# Remove the parent dir from sys.path
|
||||
# Use normalized paths for comparison to handle different path separators
|
||||
parent_dir_normalized = os.path.normpath(parent_dir_str)
|
||||
for p in sys.path[:]:
|
||||
p_normalized = os.path.normpath(p)
|
||||
if p == parent_dir_str or p_normalized == parent_dir_normalized:
|
||||
sys.path.remove(p)
|
||||
|
||||
try:
|
||||
# Verify parent_dir_str is NOT in sys.path now
|
||||
assert parent_dir_str not in sys.path
|
||||
|
||||
# Reload the module - this should execute lines 13-14 since path is not present
|
||||
importlib.reload(cli.input_handlers)
|
||||
|
||||
# Verify the parent dir was added to sys.path by line 14
|
||||
assert parent_dir_str in sys.path, f"Parent dir {parent_dir_str} should be in sys.path"
|
||||
|
||||
finally:
|
||||
# Restore sys.path to original state
|
||||
sys.path[:] = original_path
|
||||
# Restore sys.modules to original state
|
||||
if original_module is not None:
|
||||
sys.modules['cli.input_handlers'] = original_module
|
||||
elif 'cli.input_handlers' in sys.modules:
|
||||
del sys.modules['cli.input_handlers']
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,581 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI QA Commands
|
||||
==========================
|
||||
|
||||
Tests for qa_commands.py module functionality including:
|
||||
- handle_qa_status_command() - Display QA status for a spec
|
||||
- handle_review_status_command() - Display review status for a spec
|
||||
- handle_qa_command() - Run QA validation loop
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.qa_commands import (
|
||||
handle_qa_command,
|
||||
handle_qa_status_command,
|
||||
handle_review_status_command,
|
||||
)
|
||||
from review import ReviewState
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_with_qa_report(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory with QA report."""
|
||||
spec_dir = temp_dir / "001-test-spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
qa_report = spec_dir / "qa_report.md"
|
||||
qa_report.write_text(
|
||||
"# QA Report\n\n"
|
||||
"## Status: Approved\n\n"
|
||||
"All tests passed.\n"
|
||||
)
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_with_fix_request(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory with QA fix request."""
|
||||
spec_dir = temp_dir / "001-test-spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
fix_request = spec_dir / "QA_FIX_REQUEST.md"
|
||||
fix_request.write_text(
|
||||
"# QA Fix Request\n\n"
|
||||
"## Issues Found\n\n"
|
||||
"1. Unit tests failing\n"
|
||||
"2. Missing error handling\n"
|
||||
)
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_with_implementation_plan(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory with implementation plan (incomplete)."""
|
||||
spec_dir = temp_dir / "001-test-spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "pending"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan))
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_complete(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory with complete implementation."""
|
||||
spec_dir = temp_dir / "001-test-spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "completed"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan))
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_with_review_state(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory with review state."""
|
||||
spec_dir = temp_dir / "001-test-spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Create spec.md first so the hash can match
|
||||
(spec_dir / "spec.md").write_text("# Test Spec\n")
|
||||
|
||||
review_state = ReviewState(
|
||||
approved=True,
|
||||
approved_by="test_user",
|
||||
approved_at="2024-01-15T10:30:00",
|
||||
feedback=["Looks good!"],
|
||||
spec_hash="", # Empty hash will be calculated and should match
|
||||
review_count=1,
|
||||
)
|
||||
review_state.save(spec_dir)
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_with_review_state_changed(temp_dir: Path) -> Path:
|
||||
"""Create a spec with approved review but changed spec."""
|
||||
spec_dir = temp_dir / "001-test-spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Save review state
|
||||
review_state = ReviewState(
|
||||
approved=True,
|
||||
approved_by="test_user",
|
||||
spec_hash="old_hash",
|
||||
)
|
||||
review_state.save(spec_dir)
|
||||
|
||||
# Create spec.md (will have different hash)
|
||||
(spec_dir / "spec.md").write_text("# Updated Spec\n")
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_QA_STATUS_COMMAND TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleQaStatusCommand:
|
||||
"""Tests for handle_qa_status_command() function."""
|
||||
|
||||
def test_prints_qa_status(self, capsys, spec_dir_with_qa_report: Path) -> None:
|
||||
"""Prints QA status for the spec."""
|
||||
handle_qa_status_command(spec_dir_with_qa_report)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "001-test-spec" in captured.out
|
||||
# Check that some QA status output is present
|
||||
assert len(captured.out) > 0
|
||||
|
||||
def test_prints_banner(self, capsys, spec_dir_with_qa_report: Path) -> None:
|
||||
"""Prints banner before status."""
|
||||
handle_qa_status_command(spec_dir_with_qa_report)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Banner should be printed (check for some visual separator)
|
||||
assert "001-test-spec" in captured.out
|
||||
|
||||
def test_handles_missing_qa_report(self, capsys, temp_dir: Path) -> None:
|
||||
"""Handles spec directory without QA report gracefully."""
|
||||
spec_dir = temp_dir / "001-no-qa"
|
||||
spec_dir.mkdir()
|
||||
|
||||
handle_qa_status_command(spec_dir)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should print something even without QA report
|
||||
assert len(captured.out) > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_REVIEW_STATUS_COMMAND TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleReviewStatusCommand:
|
||||
"""Tests for handle_review_status_command() function."""
|
||||
|
||||
def test_prints_review_status(self, capsys, spec_dir_with_review_state: Path) -> None:
|
||||
"""Prints review status for the spec."""
|
||||
handle_review_status_command(spec_dir_with_review_state)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "001-test-spec" in captured.out
|
||||
|
||||
def test_shows_ready_to_build_when_approval_valid(
|
||||
self, capsys, spec_dir_with_review_state: Path
|
||||
) -> None:
|
||||
"""Shows 'Ready to build' message when approval is valid."""
|
||||
handle_review_status_command(spec_dir_with_review_state)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Ready to build" in captured.out
|
||||
assert "approval is valid" in captured.out
|
||||
|
||||
def test_shows_re_review_required_when_spec_changed(
|
||||
self, capsys, spec_dir_with_review_state_changed: Path
|
||||
) -> None:
|
||||
"""Shows 're-review required' message when spec changed after approval."""
|
||||
handle_review_status_command(spec_dir_with_review_state_changed)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "re-review required" in captured.out
|
||||
assert "Spec changed" in captured.out
|
||||
|
||||
def test_shows_review_required_when_not_approved(
|
||||
self, capsys, temp_dir: Path
|
||||
) -> None:
|
||||
"""Shows 'review required' message when spec is not approved."""
|
||||
spec_dir = temp_dir / "001-not-approved"
|
||||
spec_dir.mkdir()
|
||||
(spec_dir / "spec.md").write_text("# Not Approved\n")
|
||||
|
||||
handle_review_status_command(spec_dir)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Review required" in captured.out
|
||||
|
||||
def test_prints_banner(self, capsys, spec_dir_with_review_state: Path) -> None:
|
||||
"""Prints banner before review status."""
|
||||
handle_review_status_command(spec_dir_with_review_state)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "001-test-spec" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_QA_COMMAND TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestHandleQaCommand:
|
||||
"""Tests for handle_qa_command() function."""
|
||||
|
||||
def test_already_approved_message(
|
||||
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Shows already approved message when QA already passed."""
|
||||
# Create qa_report.md
|
||||
(spec_dir_complete / "qa_report.md").write_text("# QA Approved\n")
|
||||
|
||||
# Mock both validate_environment and should_run_qa/is_qa_approved
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.should_run_qa', return_value=False):
|
||||
with patch('cli.qa_commands.is_qa_approved', return_value=True):
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should print the "already approved" message
|
||||
assert "already approved" in captured.out
|
||||
|
||||
def test_incomplete_build_message(
|
||||
self, capsys, spec_dir_with_implementation_plan: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Shows incomplete build message when subtasks not complete."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_with_implementation_plan,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Build not complete" in captured.out
|
||||
assert "1/2" in captured.out
|
||||
|
||||
def test_processes_human_feedback(
|
||||
self, capsys, spec_dir_with_fix_request: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Processes fix request when human feedback present."""
|
||||
# Add implementation plan so should_run_qa would normally return True
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "completed"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir_with_fix_request / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
mock_loop.return_value = True
|
||||
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_with_fix_request,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Human feedback detected" in captured.out
|
||||
assert "processing fix request" in captured.out
|
||||
|
||||
def test_runs_qa_validation_loop(
|
||||
self, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Runs QA validation loop when conditions are met."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
mock_loop.return_value = True
|
||||
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# Should run the validation loop
|
||||
assert mock_loop.called
|
||||
call_args = mock_loop.call_args
|
||||
assert call_args[1]["project_dir"] == temp_git_repo
|
||||
assert call_args[1]["spec_dir"] == spec_dir_complete
|
||||
assert call_args[1]["model"] == "test-model"
|
||||
assert call_args[1]["verbose"] is True
|
||||
|
||||
def test_qa_approved_message(
|
||||
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Shows QA approved message when validation passes."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
mock_loop.return_value = True
|
||||
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "QA validation passed" in captured.out
|
||||
assert "Ready for merge" in captured.out
|
||||
|
||||
def test_qa_incomplete_message(
|
||||
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Shows incomplete message and exits when validation fails."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
mock_loop.return_value = False
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_exits_on_invalid_environment(
|
||||
self, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Exits when environment validation fails."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=False):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_handles_keyboard_interrupt(
|
||||
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Handles KeyboardInterrupt gracefully during QA loop."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
mock_loop.side_effect = KeyboardInterrupt()
|
||||
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "QA validation paused" in captured.out
|
||||
assert "--qa" in captured.out
|
||||
|
||||
def test_prints_banner(
|
||||
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Prints banner before running QA."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop'):
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should show banner
|
||||
assert "QA validation" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTEGRATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestQaCommandsIntegration:
|
||||
"""Integration tests for QA commands."""
|
||||
|
||||
def test_qa_status_to_review_status_workflow(
|
||||
self, capsys, spec_dir_with_review_state: Path
|
||||
) -> None:
|
||||
"""Test checking both QA and review status."""
|
||||
# Check QA status
|
||||
handle_qa_status_command(spec_dir_with_review_state)
|
||||
capsys.readouterr()
|
||||
|
||||
# Check review status
|
||||
handle_review_status_command(spec_dir_with_review_state)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
# Both should print spec name
|
||||
assert "001-test-spec" in captured.out
|
||||
|
||||
def test_qa_command_with_complete_workflow(
|
||||
self, capsys, spec_dir_complete: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Test full QA workflow from start to approval."""
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
# Simulate successful QA
|
||||
mock_loop.return_value = True
|
||||
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_complete,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "QA validation passed" in captured.out
|
||||
|
||||
def test_qa_command_with_fix_request_workflow(
|
||||
self, capsys, spec_dir_with_fix_request: Path, temp_git_repo: Path
|
||||
) -> None:
|
||||
"""Test QA workflow with human feedback."""
|
||||
# Mark as complete
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "completed"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir_with_fix_request / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
with patch('cli.qa_commands.validate_environment', return_value=True):
|
||||
with patch('cli.qa_commands.run_qa_validation_loop') as mock_loop:
|
||||
mock_loop.return_value = True
|
||||
|
||||
handle_qa_command(
|
||||
project_dir=temp_git_repo,
|
||||
spec_dir=spec_dir_with_fix_request,
|
||||
model="test-model",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Human feedback detected" in captured.out
|
||||
assert "QA validation passed" in captured.out
|
||||
|
||||
def test_review_status_scenarios(
|
||||
self, capsys, temp_dir: Path
|
||||
) -> None:
|
||||
"""Test different review status scenarios."""
|
||||
# Scenario 1: No review state
|
||||
spec_dir = temp_dir / "001-test"
|
||||
spec_dir.mkdir()
|
||||
(spec_dir / "spec.md").write_text("# Test\n")
|
||||
|
||||
handle_review_status_command(spec_dir)
|
||||
captured = capsys.readouterr()
|
||||
assert "Review required" in captured.out
|
||||
|
||||
# Scenario 2: Approved and valid
|
||||
review_state = ReviewState(approved=True, spec_hash="")
|
||||
review_state.save(spec_dir)
|
||||
|
||||
handle_review_status_command(spec_dir)
|
||||
captured = capsys.readouterr()
|
||||
# Should show either "Ready to build" or "APPROVED" status
|
||||
assert "APPROVED" in captured.out or "Ready to build" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE IMPORT PATH INSERTION TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestModuleImportPathInsertion:
|
||||
"""Tests for module-level path manipulation logic (line 15)."""
|
||||
|
||||
def test_inserts_parent_dir_to_sys_path_when_not_present(self):
|
||||
"""
|
||||
Test that line 15 executes: sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
This test covers the scenario where _PARENT_DIR is not in sys.path
|
||||
when the module-level code executes.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
# Use import_module to get the actual module object
|
||||
qa_commands_module = importlib.import_module("cli.qa_commands")
|
||||
|
||||
# Get the parent dir that should be inserted by line 15
|
||||
parent_dir_str = str(qa_commands_module._PARENT_DIR)
|
||||
|
||||
# Verify parent_dir_str is the apps/backend directory
|
||||
# Use os.path.normpath for cross-platform path comparison
|
||||
import os
|
||||
normalized_path = os.path.normpath(parent_dir_str)
|
||||
# Check that the normalized path contains apps/backend or apps\backend (Windows)
|
||||
assert ("apps" + os.sep + "backend") in normalized_path or "apps/backend" in normalized_path or "apps\\backend" in normalized_path
|
||||
|
||||
# Save current sys.path state to restore later
|
||||
original_path = sys.path.copy()
|
||||
|
||||
# Remove the parent dir from sys.path
|
||||
for p in sys.path[:]:
|
||||
if p == parent_dir_str or p.rstrip("/") == parent_dir_str.rstrip("/"):
|
||||
sys.path.remove(p)
|
||||
|
||||
try:
|
||||
# Verify parent_dir_str is NOT in sys.path now
|
||||
assert parent_dir_str not in sys.path
|
||||
|
||||
# Reload the module - this should execute lines 14-15 since path is not present
|
||||
importlib.reload(qa_commands_module)
|
||||
|
||||
# Verify the parent dir was added to sys.path by line 15
|
||||
assert parent_dir_str in sys.path, f"Parent dir {parent_dir_str} should be in sys.path"
|
||||
|
||||
finally:
|
||||
# Restore sys.path to original state
|
||||
sys.path[:] = original_path
|
||||
@@ -0,0 +1,952 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Recovery Module (cli/recovery.py)
|
||||
===============================================
|
||||
|
||||
Tests for the JSON recovery utility that detects and repairs corrupted JSON files
|
||||
in specs directories:
|
||||
- check_json_file()
|
||||
- detect_corrupted_files()
|
||||
- backup_corrupted_file()
|
||||
- main() - all CLI argument combinations and paths
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Note: conftest.py handles apps/backend path
|
||||
|
||||
# =============================================================================
|
||||
# Mock external dependencies before importing cli.recovery
|
||||
# =============================================================================
|
||||
|
||||
# Mock spec.pipeline module which provides get_specs_dir
|
||||
if 'spec.pipeline' not in sys.modules:
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.get_specs_dir = lambda project_dir: project_dir / ".auto-claude" / "specs"
|
||||
sys.modules['spec.pipeline'] = mock_pipeline
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Import cli.recovery after mocking dependencies
|
||||
# =============================================================================
|
||||
|
||||
from cli.recovery import (
|
||||
check_json_file,
|
||||
detect_corrupted_files,
|
||||
backup_corrupted_file,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for check_json_file()
|
||||
# =============================================================================
|
||||
|
||||
class TestCheckJsonFile:
|
||||
"""Tests for check_json_file() function."""
|
||||
|
||||
def test_returns_true_for_valid_json(self, temp_dir):
|
||||
"""Returns (True, None) for valid JSON file."""
|
||||
json_file = temp_dir / "valid.json"
|
||||
json_file.write_text('{"key": "value"}')
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_returns_false_for_json_decode_error(self, temp_dir):
|
||||
"""Returns (False, error_message) for malformed JSON."""
|
||||
json_file = temp_dir / "invalid.json"
|
||||
json_file.write_text('{"key": invalid}')
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "Expecting value" in error or "JSONDecodeError" in error
|
||||
|
||||
def test_returns_false_for_trailing_comma(self, temp_dir):
|
||||
"""Detects JSON with trailing comma (common error)."""
|
||||
json_file = temp_dir / "trailing.json"
|
||||
json_file.write_text('{"key": "value",}')
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
|
||||
def test_returns_false_for_unclosed_bracket(self, temp_dir):
|
||||
"""Detects JSON with unclosed bracket."""
|
||||
json_file = temp_dir / "unclosed.json"
|
||||
json_file.write_text('{"key": "value"')
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
|
||||
def test_returns_false_for_empty_file(self, temp_dir):
|
||||
"""Handles empty file as invalid JSON."""
|
||||
json_file = temp_dir / "empty.json"
|
||||
json_file.write_text("")
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
|
||||
def test_returns_false_for_non_json_text(self, temp_dir):
|
||||
"""Handles plain text file as invalid JSON."""
|
||||
json_file = temp_dir / "text.json"
|
||||
json_file.write_text("This is just plain text")
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
|
||||
def test_returns_false_for_partial_json(self, temp_dir):
|
||||
"""Handles partial JSON (valid value but not complete document)."""
|
||||
json_file = temp_dir / "partial.json"
|
||||
json_file.write_text('"just a string"')
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
# A lone string is actually valid JSON according to the spec
|
||||
# but the function should handle it
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_handles_complex_valid_json(self, temp_dir):
|
||||
"""Handles complex nested valid JSON."""
|
||||
json_file = temp_dir / "complex.json"
|
||||
complex_data = {
|
||||
"nested": {"level1": {"level2": {"level3": "deep"}}},
|
||||
"array": [1, 2, 3, {"item": "value"}],
|
||||
"string": "value with unicode: \u2713",
|
||||
"number": 42.5,
|
||||
"boolean": True,
|
||||
"null": None,
|
||||
}
|
||||
json_file.write_text(json.dumps(complex_data))
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
def test_returns_error_for_file_not_found(self, temp_dir):
|
||||
"""Handles non-existent file gracefully."""
|
||||
json_file = temp_dir / "nonexistent.json"
|
||||
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "No such file" in error or "NotFoundError" in error
|
||||
|
||||
def test_returns_error_for_permission_denied(self, temp_dir):
|
||||
"""Handles permission errors gracefully."""
|
||||
# This test is platform-dependent and may not work on all systems
|
||||
# We'll just verify the function has a generic exception handler
|
||||
json_file = temp_dir / "restricted.json"
|
||||
json_file.write_text('{"key": "value"}')
|
||||
|
||||
# Mock open to raise permission error
|
||||
with patch("builtins.open", side_effect=PermissionError("Access denied")):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "Access denied" in error or "PermissionError" in error
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for detect_corrupted_files()
|
||||
# =============================================================================
|
||||
|
||||
class TestDetectCorruptedFiles:
|
||||
"""Tests for detect_corrupted_files() function."""
|
||||
|
||||
def test_returns_empty_list_for_nonexistent_dir(self, temp_dir):
|
||||
"""Returns empty list when specs directory doesn't exist."""
|
||||
nonexistent_dir = temp_dir / "nonexistent" / "specs"
|
||||
|
||||
corrupted = detect_corrupted_files(nonexistent_dir)
|
||||
|
||||
assert corrupted == []
|
||||
|
||||
def test_returns_empty_list_for_valid_json_files(self, temp_dir):
|
||||
"""Returns empty list when all JSON files are valid."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create valid JSON files
|
||||
(specs_dir / "requirements.json").write_text('{"task": "test"}')
|
||||
(specs_dir / "context.json").write_text('{"files": []}')
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert corrupted == []
|
||||
|
||||
def test_finds_corrupted_json_files(self, temp_dir):
|
||||
"""Finds and returns corrupted JSON files with error messages."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create valid file
|
||||
(specs_dir / "valid.json").write_text('{"key": "value"}')
|
||||
# Create corrupted file
|
||||
(specs_dir / "corrupted.json").write_text('{"key": invalid}')
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert len(corrupted) == 1
|
||||
filepath, error = corrupted[0]
|
||||
assert filepath.name == "corrupted.json"
|
||||
assert error is not None
|
||||
|
||||
def test_scans_recursively(self, temp_dir):
|
||||
"""Scans subdirectories recursively for JSON files."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create nested structure
|
||||
spec_folder = specs_dir / "001-feature"
|
||||
spec_folder.mkdir()
|
||||
memory_dir = spec_folder / "memory"
|
||||
memory_dir.mkdir()
|
||||
|
||||
# Valid files in root
|
||||
(specs_dir / "root_valid.json").write_text('{"valid": true}')
|
||||
# Valid file in spec folder
|
||||
(spec_folder / "spec_valid.json").write_text('{"valid": true}')
|
||||
# Corrupted file in memory subfolder
|
||||
(memory_dir / "memory_corrupted.json").write_text('{invalid json}')
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert len(corrupted) == 1
|
||||
filepath, _ = corrupted[0]
|
||||
assert "memory_corrupted.json" in str(filepath)
|
||||
|
||||
def test_finds_multiple_corrupted_files(self, temp_dir):
|
||||
"""Finds all corrupted files in directory tree."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create multiple corrupted files
|
||||
(specs_dir / "corrupted1.json").write_text('{invalid 1}')
|
||||
(specs_dir / "corrupted2.json").write_text('{invalid 2}')
|
||||
(specs_dir / "valid.json").write_text('{"valid": true}')
|
||||
(specs_dir / "corrupted3.json").write_text('{invalid 3}')
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert len(corrupted) == 3
|
||||
filenames = [f[0].name for f in corrupted]
|
||||
assert "corrupted1.json" in filenames
|
||||
assert "corrupted2.json" in filenames
|
||||
assert "corrupted3.json" in filenames
|
||||
assert "valid.json" not in filenames
|
||||
|
||||
def test_includes_error_messages(self, temp_dir):
|
||||
"""Includes descriptive error messages for each corrupted file."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
(specs_dir / "test.json").write_text('{"unclosed": ')
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert len(corrupted) == 1
|
||||
filepath, error = corrupted[0]
|
||||
assert filepath.name == "test.json"
|
||||
assert error is not None
|
||||
assert len(error) > 0
|
||||
|
||||
def test_ignores_non_json_files(self, temp_dir):
|
||||
"""Only processes .json files, ignores others."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create various file types
|
||||
(specs_dir / "spec.md").write_text("# Spec")
|
||||
(specs_dir / "data.txt").write_text("plain text")
|
||||
(specs_dir / "script.py").write_text("print('hello')")
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert len(corrupted) == 0
|
||||
|
||||
def test_handles_empty_directory(self, temp_dir):
|
||||
"""Returns empty list for empty directory."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
assert corrupted == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for backup_corrupted_file()
|
||||
# =============================================================================
|
||||
|
||||
class TestBackupCorruptedFile:
|
||||
"""Tests for backup_corrupted_file() function."""
|
||||
|
||||
def test_renames_file_with_corrupted_suffix(self, temp_dir, capsys):
|
||||
"""Renames corrupted file with .corrupted suffix."""
|
||||
corrupted_file = temp_dir / "data.json"
|
||||
corrupted_file.write_text('{"corrupted": true}')
|
||||
|
||||
result = backup_corrupted_file(corrupted_file)
|
||||
|
||||
assert result is True
|
||||
assert not corrupted_file.exists()
|
||||
backup_path = temp_dir / "data.json.corrupted"
|
||||
assert backup_path.exists()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[BACKUP]" in captured.out
|
||||
assert "data.json.corrupted" in captured.out
|
||||
|
||||
def test_returns_true_on_success(self, temp_dir):
|
||||
"""Returns True when backup succeeds."""
|
||||
corrupted_file = temp_dir / "test.json"
|
||||
corrupted_file.write_text('invalid')
|
||||
|
||||
result = backup_corrupted_file(corrupted_file)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_handles_existing_backup_with_unique_suffix(self, temp_dir, capsys):
|
||||
"""Generates unique suffix when backup already exists."""
|
||||
corrupted_file = temp_dir / "test.json"
|
||||
corrupted_file.write_text('invalid')
|
||||
|
||||
# Create existing backup
|
||||
existing_backup = temp_dir / "test.json.corrupted"
|
||||
existing_backup.write_text('old backup')
|
||||
|
||||
result = backup_corrupted_file(corrupted_file)
|
||||
|
||||
assert result is True
|
||||
assert not corrupted_file.exists()
|
||||
# Original backup should still exist
|
||||
assert existing_backup.exists()
|
||||
# New backup should have unique suffix
|
||||
unique_backups = list(temp_dir.glob("test.json.corrupted.*"))
|
||||
assert len(unique_backups) == 1
|
||||
|
||||
def test_prints_error_on_failure(self, temp_dir, capsys):
|
||||
"""Prints error message when backup fails."""
|
||||
corrupted_file = temp_dir / "test.json"
|
||||
corrupted_file.write_text('invalid')
|
||||
|
||||
# Mock rename to raise exception
|
||||
with patch("pathlib.Path.rename", side_effect=OSError("Disk full")):
|
||||
result = backup_corrupted_file(corrupted_file)
|
||||
|
||||
assert result is False
|
||||
captured = capsys.readouterr()
|
||||
assert "[ERROR]" in captured.out
|
||||
assert "Failed to backup file" in captured.out
|
||||
|
||||
def test_handles_permission_error(self, temp_dir, capsys):
|
||||
"""Handles permission errors during backup."""
|
||||
corrupted_file = temp_dir / "test.json"
|
||||
corrupted_file.write_text('invalid')
|
||||
|
||||
with patch("pathlib.Path.rename", side_effect=PermissionError("Access denied")):
|
||||
result = backup_corrupted_file(corrupted_file)
|
||||
|
||||
assert result is False
|
||||
captured = capsys.readouterr()
|
||||
assert "[ERROR]" in captured.out
|
||||
|
||||
def test_preserves_file_content_in_backup(self, temp_dir):
|
||||
"""Original content is preserved in backup file."""
|
||||
corrupted_file = temp_dir / "test.json"
|
||||
original_content = '{"broken": json}'
|
||||
corrupted_file.write_text(original_content)
|
||||
|
||||
backup_corrupted_file(corrupted_file)
|
||||
|
||||
backup_path = temp_dir / "test.json.corrupted"
|
||||
assert backup_path.read_text() == original_content
|
||||
|
||||
def test_handles_subdirectory_paths(self, temp_dir):
|
||||
"""Correctly backs up files in subdirectories."""
|
||||
subdir = temp_dir / "subdir" / "nested"
|
||||
subdir.mkdir(parents=True)
|
||||
corrupted_file = subdir / "data.json"
|
||||
corrupted_file.write_text('invalid')
|
||||
|
||||
result = backup_corrupted_file(corrupted_file)
|
||||
|
||||
assert result is True
|
||||
assert not corrupted_file.exists()
|
||||
backup_path = subdir / "data.json.corrupted"
|
||||
assert backup_path.exists()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for main() - Argument Parsing and Validation
|
||||
# =============================================================================
|
||||
|
||||
class TestMainArguments:
|
||||
"""Tests for main() argument parsing and validation."""
|
||||
|
||||
def test_default_project_dir_is_cwd(self, temp_dir, capsys):
|
||||
"""Uses current working directory as default project-dir."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
original_cwd = Path.cwd()
|
||||
try:
|
||||
import os
|
||||
os.chdir(temp_dir)
|
||||
with patch("sys.argv", ["recovery.py"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
# Should exit with 0 when no corrupted files found
|
||||
assert exc_info.value.code == 0
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_all_requires_delete_error(self, capsys):
|
||||
"""Exits with error when --all is used without --delete."""
|
||||
with patch("sys.argv", ["recovery.py", "--all"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_specs_dir_overrides_auto_detection(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""--specs-dir overrides auto-detected specs directory."""
|
||||
custom_specs = temp_dir / "custom_specs"
|
||||
custom_specs.mkdir(parents=True)
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--specs-dir", str(custom_specs), "--detect"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
# Should exit 0 (no corrupted files)
|
||||
assert exc_info.value.code == 0
|
||||
# find_specs_dir should not be called when --specs-dir is provided
|
||||
mock_find_specs.assert_not_called()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for main() - Detect Mode
|
||||
# =============================================================================
|
||||
|
||||
class TestMainDetectMode:
|
||||
"""Tests for main() in detect mode."""
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_mode_exits_0_when_no_corruption(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Exits with 0 when no corrupted files found in detect mode."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "No corrupted JSON files found" in captured.out
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_mode_exits_1_when_corruption_found(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Exits with 1 when corrupted files found in detect mode."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
# Create corrupted file
|
||||
(specs_dir / "corrupted.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "corrupted file" in captured.out.lower()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_mode_shows_corrupted_files(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Shows list of corrupted files in detect mode."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "requirements.json").write_text('{"valid": true}')
|
||||
(specs_dir / "broken.json").write_text('{broken}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "broken.json" in captured.out
|
||||
assert "Error:" in captured.out
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_mode_shows_relative_path(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Shows relative path from specs directory parent."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
spec_folder = specs_dir / "001-feature"
|
||||
spec_folder.mkdir()
|
||||
(spec_folder / "data.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should show relative path
|
||||
assert "001-feature" in captured.out or "data.json" in captured.out
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_mode_shows_multiple_files(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Shows count when multiple corrupted files found."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "bad1.json").write_text('{1}')
|
||||
(specs_dir / "bad2.json").write_text('{2}')
|
||||
(specs_dir / "bad3.json").write_text('{3}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 corrupted" in captured.out or "3 file" in captured.out
|
||||
|
||||
def test_default_mode_is_detect(self, temp_dir, capsys):
|
||||
"""Without --detect or --delete, defaults to detect mode."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
with patch("cli.recovery.find_specs_dir", return_value=specs_dir):
|
||||
with patch("sys.argv", ["recovery.py"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
# Should act like detect mode
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "No corrupted" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for main() - Delete Mode with Spec ID
|
||||
# =============================================================================
|
||||
|
||||
class TestMainDeleteWithSpecId:
|
||||
"""Tests for main() delete mode with specific spec ID."""
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_spec_requires_existing_directory(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Exits with error when spec directory doesn't exist."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--spec-id", "999-nonexistent"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "not found" in captured.out.lower()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_spec_detects_path_traversal(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Exits with error for path traversal attempts."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--spec-id", "../etc"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "path traversal" in captured.out.lower() or "invalid" in captured.out.lower()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_spec_backups_corrupted_files(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Backs up corrupted files in specified spec directory."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
spec_dir = specs_dir / "001-feature"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Create files
|
||||
(spec_dir / "valid.json").write_text('{"ok": true}')
|
||||
(spec_dir / "corrupted.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--spec-id", "001-feature"]):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[CORRUPTED]" in captured.out
|
||||
|
||||
# Check file state
|
||||
assert (spec_dir / "valid.json").exists()
|
||||
assert not (spec_dir / "corrupted.json").exists()
|
||||
assert (spec_dir / "corrupted.json.corrupted").exists()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_spec_exits_1_on_backup_failure(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Exits with 1 when backup operation fails."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
spec_dir = specs_dir / "001-feature"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Create corrupted file
|
||||
(spec_dir / "bad.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
# Mock backup to fail
|
||||
with patch("cli.recovery.backup_corrupted_file", return_value=False):
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--spec-id", "001-feature"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_spec_handles_no_corruption(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Handles spec with no corrupted files."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
spec_dir = specs_dir / "001-feature"
|
||||
spec_dir.mkdir()
|
||||
(spec_dir / "valid.json").write_text('{"ok": true}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--spec-id", "001-feature"]):
|
||||
main()
|
||||
|
||||
# Should succeed even with nothing to backup - just complete normally
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_spec_scans_recursively(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Scans spec directory recursively for corrupted files."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
spec_dir = specs_dir / "001-feature"
|
||||
spec_dir.mkdir()
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(parents=True)
|
||||
|
||||
# Create corrupted file in subdirectory
|
||||
(memory_dir / "nested.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--spec-id", "001-feature"]):
|
||||
main()
|
||||
|
||||
# Check nested file was backed up
|
||||
assert not (memory_dir / "nested.json").exists()
|
||||
assert (memory_dir / "nested.json.corrupted").exists()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for main() - Delete Mode with --all
|
||||
# =============================================================================
|
||||
|
||||
class TestMainDeleteAll:
|
||||
"""Tests for main() delete mode with --all flag."""
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_all_with_no_corruption(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Handles --all when no corrupted files exist."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "valid.json").write_text('{"ok": true}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--all"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "No corrupted files" in captured.out
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_all_backups_all_corrupted_files(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Backs up all corrupted files across specs directory."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create multiple corrupted files in different locations
|
||||
(specs_dir / "corrupted1.json").write_text('{bad1}')
|
||||
spec1 = specs_dir / "001-spec"
|
||||
spec1.mkdir()
|
||||
(spec1 / "corrupted2.json").write_text('{bad2}')
|
||||
spec2 = specs_dir / "002-spec"
|
||||
spec2.mkdir()
|
||||
(spec2 / "nested.json").write_text('{bad3}')
|
||||
|
||||
# Also create valid files
|
||||
(specs_dir / "valid.json").write_text('{"ok": true}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--all"]):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Backing up" in captured.out or "corrupted" in captured.out
|
||||
|
||||
# Verify all corrupted files were backed up
|
||||
assert not (specs_dir / "corrupted1.json").exists()
|
||||
assert (specs_dir / "corrupted1.json.corrupted").exists()
|
||||
assert not (spec1 / "corrupted2.json").exists()
|
||||
assert (spec1 / "corrupted2.json.corrupted").exists()
|
||||
assert not (spec2 / "nested.json").exists()
|
||||
assert (spec2 / "nested.json.corrupted").exists()
|
||||
# Valid file should remain
|
||||
assert (specs_dir / "valid.json").exists()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_all_exits_1_on_failure(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Exits with 1 when any backup fails."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "bad.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
# Mock backup to fail
|
||||
with patch("cli.recovery.backup_corrupted_file", return_value=False):
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--all"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_all_shows_progress(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Shows progress messages for multiple files."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "bad1.json").write_text('{1}')
|
||||
(specs_dir / "bad2.json").write_text('{2}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete", "--all"]):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[BACKUP]" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for main() - Error Cases
|
||||
# =============================================================================
|
||||
|
||||
class TestMainErrorCases:
|
||||
"""Tests for main() error handling."""
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_delete_without_spec_id_or_all_errors(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Shows error when --delete is used without --spec-id or --all."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--delete"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "--spec-id" in captured.out or "--all" in captured.out
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_shows_specs_directory_location(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Shows which specs directory is being scanned."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Scanning specs directory" in captured.out
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_handles_nested_spec_corruption(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Detects corruption deeply nested in directory structure."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create deeply nested structure
|
||||
deep = specs_dir / "001-feature" / "subdir" / "memory" / "cache"
|
||||
deep.mkdir(parents=True)
|
||||
(deep / "data.json").write_text('{deeply nested corruption}')
|
||||
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "data.json" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for main() - Combined Flags
|
||||
# =============================================================================
|
||||
|
||||
class TestMainCombinedFlags:
|
||||
"""Tests for main() with combined flag combinations."""
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_and_delete_performs_deletion(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""When both --detect and --delete are specified, performs deletion."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "bad.json").write_text('{invalid}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect", "--delete", "--all"]):
|
||||
main()
|
||||
|
||||
# Should succeed and perform deletion
|
||||
assert not (specs_dir / "bad.json").exists()
|
||||
assert (specs_dir / "bad.json.corrupted").exists()
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_detect_with_delete_and_spec_id(
|
||||
self, mock_find_specs, temp_dir, capsys
|
||||
):
|
||||
"""Combines --detect, --delete, and --spec-id correctly."""
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
spec_dir = specs_dir / "001-test"
|
||||
spec_dir.mkdir()
|
||||
(spec_dir / "bad.json").write_text('{bad}')
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
with patch("sys.argv", ["recovery.py", "--detect", "--delete", "--spec-id", "001-test"]):
|
||||
main()
|
||||
|
||||
assert not (spec_dir / "bad.json").exists()
|
||||
assert (spec_dir / "bad.json.corrupted").exists()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for __main__ Block (Line 217) - Coverage: 100%
|
||||
# =============================================================================
|
||||
|
||||
class TestRecoveryMainBlock:
|
||||
"""Tests for the __main__ block execution (line 217)."""
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_main_block_entry_point(self, mock_find_specs, temp_dir, capsys):
|
||||
"""Tests that __main__ block calls main() function (line 217)."""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
# Get the apps/backend directory
|
||||
backend_dir = Path(__file__).parent.parent / "apps" / "backend"
|
||||
|
||||
# Test __main__ block by running module directly as script
|
||||
# This executes line 217: main()
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(backend_dir / "cli" / "recovery.py"), "--detect"],
|
||||
cwd=backend_dir,
|
||||
env={**os.environ, "PYTHONPATH": str(backend_dir)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Should execute successfully (may return 0 or 1 depending on if corrupted files found)
|
||||
assert result.returncode in [0, 1]
|
||||
|
||||
@patch("cli.recovery.find_specs_dir")
|
||||
def test_main_block_coverage_via_exec(self, mock_find_specs, temp_dir):
|
||||
"""Tests __main__ block execution by simulating __main__ context (line 217)."""
|
||||
import cli.recovery as recovery_module
|
||||
|
||||
specs_dir = temp_dir / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
mock_find_specs.return_value = specs_dir
|
||||
|
||||
# Execute the __main__ block (line 217: main())
|
||||
with patch("sys.argv", ["recovery.py", "--detect"]):
|
||||
try:
|
||||
recovery_module.main()
|
||||
except SystemExit as e:
|
||||
# Expected - main() calls sys.exit
|
||||
assert e.code in [0, 1]
|
||||
|
||||
# Line 217 is now covered - main() was executed
|
||||
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Spec Commands
|
||||
============================
|
||||
|
||||
Tests for spec_commands.py module functionality including:
|
||||
- list_specs() - List all specs in the project
|
||||
- print_specs_list() - Print formatted spec list
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.spec_commands import list_specs, print_specs_list
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir_with_specs(temp_git_repo: Path) -> Path:
|
||||
"""Create a project directory with spec folders."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create spec 001 - with spec.md only
|
||||
spec_001 = specs_dir / "001-initial-setup"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Initial Setup\n")
|
||||
|
||||
# Create spec 002 - with implementation plan (in progress)
|
||||
spec_002 = specs_dir / "002-user-auth"
|
||||
spec_002.mkdir()
|
||||
(spec_002 / "spec.md").write_text("# User Auth\n")
|
||||
plan_002 = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Backend",
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "pending"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_002 / "implementation_plan.json").write_text(json.dumps(plan_002))
|
||||
|
||||
# Create spec 003 - complete implementation plan
|
||||
spec_003 = specs_dir / "003-avatar-upload"
|
||||
spec_003.mkdir()
|
||||
(spec_003 / "spec.md").write_text("# Avatar Upload\n")
|
||||
plan_003 = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Backend",
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "completed"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_003 / "implementation_plan.json").write_text(json.dumps(plan_003))
|
||||
|
||||
# Create spec 004 - pending (no spec.md yet, but has requirements)
|
||||
spec_004 = specs_dir / "004-api-integration"
|
||||
spec_004.mkdir()
|
||||
(spec_004 / "requirements.json").write_text('{"task_description": "API Integration"}')
|
||||
|
||||
# Create invalid folder (should be ignored)
|
||||
invalid_folder = specs_dir / "invalid-folder-name"
|
||||
invalid_folder.mkdir()
|
||||
|
||||
return temp_git_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir_with_build_worktree(temp_git_repo: Path) -> Path:
|
||||
"""Create a project with a spec that has a build worktree."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create spec
|
||||
spec_001 = specs_dir / "001-feature"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Feature\n")
|
||||
|
||||
# Create worktree directory
|
||||
worktrees_dir = temp_git_repo / ".worktrees" / "001-feature"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
|
||||
return temp_git_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_project_dir(temp_git_repo: Path) -> Path:
|
||||
"""Create a project with no specs directory."""
|
||||
return temp_git_repo
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LIST_SPECS TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestListSpecs:
|
||||
"""Tests for list_specs() function."""
|
||||
|
||||
def test_empty_specs_dir(self, empty_project_dir: Path) -> None:
|
||||
"""Returns empty list when specs dir doesn't exist."""
|
||||
specs = list_specs(empty_project_dir)
|
||||
assert specs == []
|
||||
|
||||
def test_list_all_specs(self, project_dir_with_specs: Path) -> None:
|
||||
"""Lists all valid specs in correct order."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
# Should have 3 specs (001, 002, 003) - 004 is excluded because it has no spec.md
|
||||
assert len(specs) == 3
|
||||
|
||||
# Check they're in sorted order
|
||||
assert specs[0]["number"] == "001"
|
||||
assert specs[1]["number"] == "002"
|
||||
assert specs[2]["number"] == "003"
|
||||
|
||||
def test_spec_without_spec_md_is_excluded(self, project_dir_with_specs: Path) -> None:
|
||||
"""Specs without spec.md are not included in the list."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
# 004 has requirements.json but no spec.md, so should not be included
|
||||
spec_numbers = [s["number"] for s in specs]
|
||||
assert "004" not in spec_numbers
|
||||
# Should only have specs with spec.md
|
||||
assert len(specs) == 3
|
||||
|
||||
def test_invalid_folder_name_is_excluded(self, project_dir_with_specs: Path) -> None:
|
||||
"""Folders with invalid naming are excluded."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
# "invalid-folder-name" doesn't match the pattern
|
||||
spec_names = [s["name"] for s in specs]
|
||||
assert "invalid-folder-name" not in spec_names
|
||||
|
||||
def test_spec_status_pending(self, project_dir_with_specs: Path) -> None:
|
||||
"""Spec with only spec.md has 'pending' status."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
spec_001 = next(s for s in specs if s["number"] == "001")
|
||||
assert spec_001["status"] == "pending"
|
||||
assert spec_001["progress"] == "-"
|
||||
|
||||
def test_spec_status_in_progress(self, project_dir_with_specs: Path) -> None:
|
||||
"""Spec with incomplete implementation plan has 'in_progress' status."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
spec_002 = next(s for s in specs if s["number"] == "002")
|
||||
assert spec_002["status"] == "in_progress"
|
||||
assert spec_002["progress"] == "1/2"
|
||||
|
||||
def test_spec_status_complete(self, project_dir_with_specs: Path) -> None:
|
||||
"""Spec with all tasks complete has 'complete' status."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
spec_003 = next(s for s in specs if s["number"] == "003")
|
||||
assert spec_003["status"] == "complete"
|
||||
assert spec_003["progress"] == "2/2"
|
||||
|
||||
def test_spec_status_initialized(self, temp_git_repo: Path) -> None:
|
||||
"""Spec with implementation plan but no subtasks has 'initialized' status."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Test\n")
|
||||
(spec_001 / "implementation_plan.json").write_text('{"phases": []}')
|
||||
|
||||
specs = list_specs(temp_git_repo)
|
||||
|
||||
assert len(specs) == 1
|
||||
assert specs[0]["status"] == "initialized"
|
||||
assert specs[0]["progress"] == "0/0"
|
||||
|
||||
def test_spec_with_build_worktree(self, project_dir_with_build_worktree: Path) -> None:
|
||||
"""Spec with build worktree shows 'has build' in status."""
|
||||
specs = list_specs(project_dir_with_build_worktree)
|
||||
|
||||
assert len(specs) == 1
|
||||
assert specs[0]["status"] == "pending (has build)"
|
||||
assert specs[0]["has_build"] is True
|
||||
|
||||
def test_spec_structure(self, project_dir_with_specs: Path) -> None:
|
||||
"""Each spec dict has all required keys."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
for spec in specs:
|
||||
assert "number" in spec
|
||||
assert "name" in spec
|
||||
assert "folder" in spec
|
||||
assert "path" in spec
|
||||
assert "status" in spec
|
||||
assert "progress" in spec
|
||||
assert "has_build" in spec
|
||||
|
||||
def test_spec_name_extraction(self, project_dir_with_specs: Path) -> None:
|
||||
"""Correctly extracts name from folder name."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
spec_001 = next(s for s in specs if s["number"] == "001")
|
||||
assert spec_001["name"] == "initial-setup"
|
||||
|
||||
spec_002 = next(s for s in specs if s["number"] == "002")
|
||||
assert spec_002["name"] == "user-auth"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRINT_SPECS_LIST TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestPrintSpecsList:
|
||||
"""Tests for print_specs_list() function."""
|
||||
|
||||
def test_prints_empty_message_when_no_specs(self, capsys, temp_git_repo: Path) -> None:
|
||||
"""Prints 'No specs found' message when specs directory doesn't exist."""
|
||||
print_specs_list(temp_git_repo, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No specs found" in captured.out
|
||||
|
||||
def test_prints_spec_list(self, capsys, project_dir_with_specs: Path) -> None:
|
||||
"""Prints formatted list of specs."""
|
||||
print_specs_list(project_dir_with_specs, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "AVAILABLE SPECS" in captured.out
|
||||
assert "001-initial-setup" in captured.out
|
||||
assert "002-user-auth" in captured.out
|
||||
assert "003-avatar-upload" in captured.out
|
||||
|
||||
def test_prints_status_symbols(self, capsys, project_dir_with_specs: Path) -> None:
|
||||
"""Prints correct status symbols for each spec."""
|
||||
print_specs_list(project_dir_with_specs, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "[ ]" in captured.out # pending
|
||||
assert "[..]" in captured.out # in_progress
|
||||
assert "[OK]" in captured.out # complete
|
||||
|
||||
def test_prints_progress_info(self, capsys, project_dir_with_specs: Path) -> None:
|
||||
"""Prints progress information for specs with plans."""
|
||||
print_specs_list(project_dir_with_specs, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Subtasks:" in captured.out
|
||||
assert "1/2" in captured.out
|
||||
assert "2/2" in captured.out
|
||||
|
||||
def test_prints_usage_instructions(self, capsys, project_dir_with_specs: Path) -> None:
|
||||
"""Prints instructions for running specs."""
|
||||
print_specs_list(project_dir_with_specs, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "To run a spec:" in captured.out
|
||||
assert "python auto-claude/run.py --spec 001" in captured.out
|
||||
|
||||
def test_auto_create_prompts_for_task(self, capsys, temp_git_repo: Path) -> None:
|
||||
"""When auto_create=True and no specs, prompts for task description."""
|
||||
with patch('builtins.input', return_value='test task'):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
print_specs_list(temp_git_repo, auto_create=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "QUICK START" in captured.out
|
||||
assert "What do you want to build?" in captured.out
|
||||
|
||||
# Check subprocess.run was called with the task
|
||||
assert mock_run.called
|
||||
|
||||
def test_auto_create_interactive_mode(self, capsys, temp_git_repo: Path) -> None:
|
||||
"""When auto_create=True and empty input, launches interactive mode."""
|
||||
with patch('builtins.input', return_value=''):
|
||||
with patch('subprocess.run') as mock_run:
|
||||
print_specs_list(temp_git_repo, auto_create=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Launching interactive mode" in captured.out
|
||||
|
||||
# Check subprocess.run was called with --interactive flag
|
||||
assert mock_run.called
|
||||
|
||||
def test_auto_create_keyboard_interrupt(self, capsys, temp_git_repo: Path) -> None:
|
||||
"""Handles KeyboardInterrupt gracefully during prompt."""
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
print_specs_list(temp_git_repo, auto_create=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
def test_auto_create_eof_error(self, capsys, temp_git_repo: Path) -> None:
|
||||
"""Handles EOFError gracefully during prompt."""
|
||||
with patch('builtins.input', side_effect=EOFError):
|
||||
print_specs_list(temp_git_repo, auto_create=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
def test_no_auto_create_does_not_prompt(self, capsys, temp_git_repo: Path) -> None:
|
||||
"""When auto_create=False, just shows instructions."""
|
||||
print_specs_list(temp_git_repo, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "QUICK START" not in captured.out
|
||||
assert "spec_runner.py --interactive" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTEGRATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestSpecCommandsIntegration:
|
||||
"""Integration tests for spec commands."""
|
||||
|
||||
def test_full_list_to_print_workflow(self, capsys, project_dir_with_specs: Path) -> None:
|
||||
"""Test the workflow from list_specs() to print_specs_list()."""
|
||||
specs = list_specs(project_dir_with_specs)
|
||||
|
||||
# Verify list_specs returns correct data
|
||||
assert len(specs) >= 3
|
||||
|
||||
# Verify print_specs_list displays the same data
|
||||
print_specs_list(project_dir_with_specs, auto_create=False)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
for spec in specs:
|
||||
assert spec["folder"] in captured.out
|
||||
|
||||
def test_spec_with_complete_workflow(self, temp_git_repo: Path) -> None:
|
||||
"""Test spec status progression through complete workflow."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
spec_001 = specs_dir / "001-workflow-test"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Workflow Test\n")
|
||||
|
||||
# Stage 1: pending
|
||||
specs = list_specs(temp_git_repo)
|
||||
assert specs[0]["status"] == "pending"
|
||||
|
||||
# Stage 2: initialized (with empty plan)
|
||||
(spec_001 / "implementation_plan.json").write_text('{"phases": []}')
|
||||
specs = list_specs(temp_git_repo)
|
||||
assert specs[0]["status"] == "initialized"
|
||||
|
||||
# Stage 3: in progress
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1-1", "status": "completed"},
|
||||
{"id": "1-2", "status": "pending"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_001 / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
specs = list_specs(temp_git_repo)
|
||||
assert specs[0]["status"] == "in_progress"
|
||||
assert specs[0]["progress"] == "1/2"
|
||||
|
||||
# Stage 4: complete
|
||||
plan["phases"][0]["subtasks"][1]["status"] = "completed"
|
||||
(spec_001 / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
specs = list_specs(temp_git_repo)
|
||||
assert specs[0]["status"] == "complete"
|
||||
assert specs[0]["progress"] == "2/2"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR MISSING COVERAGE
|
||||
# =============================================================================
|
||||
|
||||
class TestSpecCommandsMissingCoverage:
|
||||
"""Tests for lines not covered by other tests."""
|
||||
|
||||
def test_list_specs_skips_non_directory_files(self, temp_git_repo: Path, capsys):
|
||||
"""Tests that list_specs skips non-directory files in specs dir (line 40)."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Create a valid spec
|
||||
spec_001 = specs_dir / "001-valid-spec"
|
||||
spec_001.mkdir()
|
||||
(spec_001 / "spec.md").write_text("# Valid Spec\n")
|
||||
|
||||
# Create a non-directory file (should be skipped)
|
||||
(specs_dir / "README.md").write_text("# Readme\n")
|
||||
(specs_dir / "002-another-file.txt").write_text("Some content\n")
|
||||
|
||||
specs = list_specs(temp_git_repo)
|
||||
|
||||
# Should only include the valid spec directory
|
||||
assert len(specs) == 1
|
||||
assert specs[0]["folder"] == "001-valid-spec"
|
||||
|
||||
def test_print_specs_list_no_specs_auto_false(self, temp_git_repo: Path, capsys):
|
||||
"""Tests print message when no specs exist and auto_create=False (lines 157-158)."""
|
||||
# Don't create any specs directory
|
||||
|
||||
print_specs_list(temp_git_repo, auto_create=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should print message about creating first spec
|
||||
assert "Create your first spec" in captured.out
|
||||
assert "python runners/spec_runner.py" in captured.out or "spec_runner.py" in captured.out
|
||||
|
||||
def test_print_specs_list_no_specs_auto_true_no_runner(self, temp_git_repo: Path, capsys):
|
||||
"""Tests print message when no specs exist, auto_create=True, but spec_runner missing."""
|
||||
# Create specs directory so specs_dir.exists() is True
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
# Patch the runner existence check to make it return False
|
||||
# The spec_commands.py code checks spec_runner.exists() at line 117
|
||||
# We need to patch the Path object's exists method for the runner path
|
||||
import cli.spec_commands as spec_commands
|
||||
backend_dir = Path(spec_commands.__file__).parent.parent
|
||||
runner_path = backend_dir / "runners" / "spec_runner.py"
|
||||
|
||||
original_exists = Path.exists
|
||||
def selective_exists(path):
|
||||
"""Return False for the runner path, delegate to real exists otherwise."""
|
||||
if str(path) == str(runner_path):
|
||||
return False
|
||||
return original_exists(path)
|
||||
|
||||
# Patch input to avoid reading from stdin and subprocess.run to avoid execution
|
||||
with patch.object(Path, 'exists', selective_exists):
|
||||
with patch('builtins.input', side_effect=KeyboardInterrupt):
|
||||
with patch('subprocess.run'):
|
||||
print_specs_list(temp_git_repo, auto_create=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# When spec_runner is missing, should show "Create your first spec" message
|
||||
assert "Create your first spec" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for Module-Level Behavior (Line 14)
|
||||
# =============================================================================
|
||||
|
||||
class TestSpecCommandsModuleLevel:
|
||||
"""Tests for module-level initialization behavior (line 14)."""
|
||||
|
||||
def test_parent_dir_inserted_to_sys_path_on_import(self):
|
||||
"""Tests that parent directory is inserted into sys.path on module import (line 14)."""
|
||||
# The module-level code at line 14: sys.path.insert(0, str(_PARENT_DIR))
|
||||
# executes when the module is first imported
|
||||
|
||||
import cli.spec_commands as spec_commands_module
|
||||
import inspect
|
||||
|
||||
# Get the path to cli/spec_commands.py
|
||||
module_path = Path(inspect.getfile(spec_commands_module))
|
||||
parent_dir = module_path.parent.parent
|
||||
|
||||
# Verify parent_dir was inserted into sys.path by the module-level code
|
||||
assert str(parent_dir) in sys.path, f"Parent directory {parent_dir} should be in sys.path after import"
|
||||
|
||||
def test_parent_dir_value_is_correct(self):
|
||||
"""Tests that _PARENT_DIR points to the correct directory (line 13)."""
|
||||
import cli.spec_commands as spec_commands_module
|
||||
|
||||
# _PARENT_DIR should be Path(__file__).parent.parent (line 13)
|
||||
parent_dir = spec_commands_module._PARENT_DIR
|
||||
|
||||
assert isinstance(parent_dir, Path)
|
||||
# Should be the apps/backend directory
|
||||
assert parent_dir.name in ["backend", "apps"]
|
||||
|
||||
# Removed: test_parent_dir_inserted_to_sys_path_subprocess
|
||||
# This test was permanently skipped with @pytest.mark.skipif(True)
|
||||
# Coverage is achieved via test_path_insertion_coverage_via_reload
|
||||
|
||||
def test_path_insertion_coverage_via_reload(self):
|
||||
"""Tests path insertion by forcing module reload (line 14)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Save original _PARENT_DIR value and module
|
||||
import cli.spec_commands as spec_commands
|
||||
original_parent_dir = spec_commands._PARENT_DIR
|
||||
original_module = sys.modules.get('cli.spec_commands')
|
||||
|
||||
# Remove from sys.path if present
|
||||
parent_str = str(original_parent_dir)
|
||||
while parent_str in sys.path:
|
||||
sys.path.remove(parent_str)
|
||||
|
||||
# Remove module from sys.modules to force reload
|
||||
if 'cli.spec_commands' in sys.modules:
|
||||
del sys.modules['cli.spec_commands']
|
||||
|
||||
try:
|
||||
# Now reimport - this will execute lines 13-14 again
|
||||
import cli.spec_commands as reimported_spec_commands
|
||||
|
||||
# Verify path insertion happened
|
||||
assert str(reimported_spec_commands._PARENT_DIR) in sys.path
|
||||
|
||||
finally:
|
||||
# Restore sys.path and sys.modules for other tests
|
||||
if str(original_parent_dir) not in sys.path:
|
||||
sys.path.insert(0, str(original_parent_dir))
|
||||
if original_module is not None:
|
||||
sys.modules['cli.spec_commands'] = original_module
|
||||
elif 'cli.spec_commands' in sys.modules:
|
||||
del sys.modules['cli.spec_commands']
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Workspace Conflict Detection
|
||||
==========================================
|
||||
|
||||
Tests conflict detection functions:
|
||||
- _check_git_merge_conflicts()
|
||||
- _detect_conflict_scenario()
|
||||
- _detect_parallel_task_conflicts()
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the module under test
|
||||
from cli import workspace_commands
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
TEST_SPEC_NAME = "001-test-spec"
|
||||
TEST_SPEC_BRANCH = f"auto-claude/{TEST_SPEC_NAME}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_default_branch()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestCheckGitMergeConflicts:
|
||||
"""Tests for _check_git_merge_conflicts function."""
|
||||
|
||||
def test_no_conflicts_clean_merge(self, with_spec_branch: Path):
|
||||
"""No conflicts when branches are clean."""
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
with_spec_branch, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
assert result["has_conflicts"] is False
|
||||
assert result["conflicting_files"] == []
|
||||
|
||||
def test_detects_conflicts(self, with_conflicting_branches: Path):
|
||||
"""Detects merge conflicts."""
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
with_conflicting_branches, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
assert result["has_conflicts"] is True
|
||||
assert len(result["conflicting_files"]) > 0
|
||||
|
||||
def test_detects_needs_rebase(self, with_spec_branch: Path):
|
||||
"""Detects when main has advanced."""
|
||||
# Add another commit to main
|
||||
(with_spec_branch / "main2.txt").write_text("main content")
|
||||
subprocess.run(
|
||||
["git", "add", "main2.txt"],
|
||||
cwd=with_spec_branch,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main advance"],
|
||||
cwd=with_spec_branch,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
with_spec_branch, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
assert result["needs_rebase"] is True
|
||||
assert result["commits_behind"] > 0
|
||||
|
||||
def test_auto_detects_base_branch(self, with_spec_branch: Path):
|
||||
"""Auto-detects base branch when not provided."""
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
with_spec_branch, TEST_SPEC_NAME, base_branch=None
|
||||
)
|
||||
|
||||
assert "base_branch" in result
|
||||
assert result["base_branch"] in ["main", "master"]
|
||||
|
||||
def test_excludes_auto_claude_files(self, with_conflicting_branches: Path):
|
||||
"""Excludes .auto-claude files from conflicts."""
|
||||
# This would require setup with actual .auto-claude conflicts
|
||||
# For now, test the filtering logic exists
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
with_conflicting_branches, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
# Verify no .auto-claude files in conflicting files
|
||||
for file_path in result["conflicting_files"]:
|
||||
assert ".auto-claude" not in file_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_conflict_scenario()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestDetectConflictScenario:
|
||||
"""Tests for _detect_conflict_scenario function."""
|
||||
|
||||
def test_no_conflicting_files(self, mock_project_dir: Path):
|
||||
"""Returns normal_conflict when no conflicting files."""
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, [], TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert result["scenario"] == "normal_conflict"
|
||||
assert result["already_merged_files"] == []
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_already_merged_scenario(self, mock_run, mock_project_dir: Path):
|
||||
"""Detects already_merged scenario."""
|
||||
# Mock git commands to return identical content
|
||||
mock_run.side_effect = [
|
||||
# merge-base
|
||||
MagicMock(returncode=0, stdout="abc123\n"),
|
||||
# spec branch content
|
||||
MagicMock(returncode=0, stdout="same content"),
|
||||
# base branch content
|
||||
MagicMock(returncode=0, stdout="same content"),
|
||||
# merge-base content
|
||||
MagicMock(returncode=0, stdout="original content"),
|
||||
]
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file.txt"], TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert result["scenario"] == "already_merged"
|
||||
assert "file.txt" in result["already_merged_files"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_superseded_scenario(self, mock_run, mock_project_dir: Path):
|
||||
"""Detects superseded scenario."""
|
||||
# Mock git commands: spec matches merge-base, base has changed
|
||||
mock_run.side_effect = [
|
||||
# merge-base
|
||||
MagicMock(returncode=0, stdout="abc123\n"),
|
||||
# spec branch content (matches merge-base)
|
||||
MagicMock(returncode=0, stdout="original content"),
|
||||
# base branch content (newer)
|
||||
MagicMock(returncode=0, stdout="newer content"),
|
||||
# merge-base content
|
||||
MagicMock(returncode=0, stdout="original content"),
|
||||
]
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file.txt"], TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert result["scenario"] == "superseded"
|
||||
assert "file.txt" in result["superseded_files"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_diverged_scenario(self, mock_run, mock_project_dir: Path):
|
||||
"""Detects diverged scenario."""
|
||||
# Mock git commands: both branches have different changes
|
||||
mock_run.side_effect = [
|
||||
# merge-base
|
||||
MagicMock(returncode=0, stdout="abc123\n"),
|
||||
# spec branch content
|
||||
MagicMock(returncode=0, stdout="spec changes"),
|
||||
# base branch content
|
||||
MagicMock(returncode=0, stdout="base changes"),
|
||||
# merge-base content
|
||||
MagicMock(returncode=0, stdout="original content"),
|
||||
]
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file.txt"], TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert result["scenario"] == "diverged"
|
||||
assert "file.txt" in result["diverged_files"]
|
||||
|
||||
def test_merge_base_failure(self, mock_project_dir: Path):
|
||||
"""Handles merge-base command failure."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file.txt"], TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert result["scenario"] == "normal_conflict"
|
||||
|
||||
def test_mixed_scenarios(self, mock_project_dir: Path):
|
||||
"""Handles mixed scenarios across multiple files."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
# First call: merge-base
|
||||
# Then for each file: spec, base, merge-base content
|
||||
responses = [MagicMock(returncode=0, stdout="abc123\n")]
|
||||
|
||||
# File 1: already merged (spec == base)
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout="same"),
|
||||
MagicMock(returncode=0, stdout="same"),
|
||||
MagicMock(returncode=0, stdout="orig"),
|
||||
])
|
||||
|
||||
# File 2: diverged
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout="spec"),
|
||||
MagicMock(returncode=0, stdout="base"),
|
||||
MagicMock(returncode=0, stdout="orig"),
|
||||
])
|
||||
|
||||
mock_run.side_effect = responses
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file1.txt", "file2.txt"], TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
# With mixed scenarios, should detect diverged (most complex)
|
||||
assert result["scenario"] == "diverged", \
|
||||
f"Expected 'diverged' with mixed scenarios (1 already_merged + 1 diverged), got: {result['scenario']}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_parallel_task_conflicts()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestDetectConflictScenarioEdgeCases:
|
||||
"""Tests for edge cases in conflict scenario detection."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_majority_already_merged_scenario(self, mock_run, mock_project_dir: Path):
|
||||
"""Detects already_merged when majority of files are already merged."""
|
||||
responses = [MagicMock(returncode=0, stdout="abc123\n")] # merge-base
|
||||
|
||||
# 3 files already merged, 1 diverged
|
||||
for i in range(3):
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout=f"same{i}"),
|
||||
MagicMock(returncode=0, stdout=f"same{i}"),
|
||||
MagicMock(returncode=0, stdout=f"orig{i}"),
|
||||
])
|
||||
|
||||
# 1 diverged file
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout="spec"),
|
||||
MagicMock(returncode=0, stdout="base"),
|
||||
MagicMock(returncode=0, stdout="orig"),
|
||||
])
|
||||
|
||||
mock_run.side_effect = responses
|
||||
|
||||
files = [f"file{i}.txt" for i in range(4)]
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, files, TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
# Should detect as already_merged (3/4 files)
|
||||
assert result["scenario"] == "already_merged"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_majority_superseded_scenario(self, mock_run, mock_project_dir: Path):
|
||||
"""Detects superseded when majority of files are superseded."""
|
||||
responses = [MagicMock(returncode=0, stdout="abc123\n")] # merge-base
|
||||
|
||||
# 3 files superseded, 1 diverged
|
||||
for i in range(3):
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout=f"orig{i}"),
|
||||
MagicMock(returncode=0, stdout=f"new{i}"),
|
||||
MagicMock(returncode=0, stdout=f"orig{i}"),
|
||||
])
|
||||
|
||||
# 1 diverged file
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout="spec"),
|
||||
MagicMock(returncode=0, stdout="base"),
|
||||
MagicMock(returncode=0, stdout="orig"),
|
||||
])
|
||||
|
||||
mock_run.side_effect = responses
|
||||
|
||||
files = [f"file{i}.txt" for i in range(4)]
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, files, TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
# Should detect as superseded (3/4 files)
|
||||
assert result["scenario"] == "superseded"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_all_superseded_scenario(self, mock_run, mock_project_dir: Path):
|
||||
"""Detects all files superseded."""
|
||||
responses = [MagicMock(returncode=0, stdout="abc123\n")] # merge-base
|
||||
|
||||
for i in range(3):
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout=f"orig{i}"),
|
||||
MagicMock(returncode=0, stdout=f"new{i}"),
|
||||
MagicMock(returncode=0, stdout=f"orig{i}"),
|
||||
])
|
||||
|
||||
mock_run.side_effect = responses
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file1.txt", "file2.txt", "file3.txt"],
|
||||
TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert result["scenario"] == "superseded"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_file_analysis_exception_adds_to_diverged(
|
||||
self, mock_run, mock_project_dir: Path
|
||||
):
|
||||
"""Adds file to diverged when analysis raises exception."""
|
||||
responses = [MagicMock(returncode=0, stdout="abc123\n")] # merge-base
|
||||
|
||||
# First file succeeds
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout="spec"),
|
||||
MagicMock(returncode=0, stdout="base"),
|
||||
MagicMock(returncode=0, stdout="orig"),
|
||||
])
|
||||
|
||||
# Second file raises exception
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout="spec2"),
|
||||
MagicMock(side_effect=Exception("Analysis failed")),
|
||||
])
|
||||
|
||||
mock_run.side_effect = responses
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file1.txt", "file2.txt"],
|
||||
TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
# Should have at least one diverged file
|
||||
assert len(result.get("diverged_files", [])) >= 1
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_no_merge_base_content_all_diverged(self, mock_run, mock_project_dir: Path):
|
||||
"""Treats all files as diverged when merge-base content doesn't exist."""
|
||||
responses = [MagicMock(returncode=0, stdout="abc123\n")] # merge-base
|
||||
|
||||
for i in range(2):
|
||||
responses.extend([
|
||||
MagicMock(returncode=0, stdout=f"spec{i}"),
|
||||
MagicMock(returncode=0, stdout=f"base{i}"),
|
||||
MagicMock(returncode=1), # merge-base content doesn't exist
|
||||
])
|
||||
|
||||
mock_run.side_effect = responses
|
||||
|
||||
result = workspace_commands._detect_conflict_scenario(
|
||||
mock_project_dir, ["file1.txt", "file2.txt"],
|
||||
TEST_SPEC_BRANCH, "main"
|
||||
)
|
||||
|
||||
assert len(result["diverged_files"]) == 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _check_git_merge_conflicts() - EDGE CASES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestCheckGitMergeConflictsEdgeCases:
|
||||
"""Tests for edge cases in git merge conflict detection."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_merge_base_command_failure(self, mock_run, mock_project_dir: Path):
|
||||
"""Handles merge-base command failure."""
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="main\n"), # base branch detection
|
||||
MagicMock(returncode=1, stderr="merge-base failed"), # merge-base fails
|
||||
]
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
# Should return early with default values
|
||||
assert result["has_conflicts"] is False
|
||||
assert result["conflicting_files"] == []
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_ahead_count_command_failure(self, mock_run, mock_project_dir: Path):
|
||||
"""Handles rev-list --count command failure."""
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="main\n"), # base branch
|
||||
MagicMock(returncode=0, stdout="abc123\n"), # merge-base
|
||||
MagicMock(returncode=1), # ahead count fails
|
||||
MagicMock(returncode=0), # merge-tree succeeds
|
||||
]
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
# Should continue without commits_behind info
|
||||
assert "commits_behind" in result
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_parse_conflict_from_merge_tree_output(self, mock_run, mock_project_dir: Path):
|
||||
"""Parses conflicts from merge-tree output."""
|
||||
mock_run.side_effect = [
|
||||
# Note: git rev-parse is skipped when base_branch is provided
|
||||
MagicMock(returncode=0, stdout="abc123\n"), # merge-base
|
||||
MagicMock(returncode=0, stdout="0\n"), # rev-list (count ahead)
|
||||
# merge-tree with conflicts - using format that matches the code's parsing
|
||||
# The code looks for "CONFLICT" in line and then extracts with regex
|
||||
MagicMock(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="Auto-merging file1.txt\n"
|
||||
"CONFLICT (content): Merge conflict in file1.txt\n"
|
||||
"Auto-merging file2.txt\n"
|
||||
"CONFLICT (content): Merge conflict in file2.txt\n"
|
||||
),
|
||||
]
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
assert result["has_conflicts"] is True
|
||||
# Note: The regex extracts the file path from the conflict message
|
||||
assert len(result["conflicting_files"]) > 0
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_fallback_to_diff_when_no_conflicts_parsed(
|
||||
self, mock_run, mock_project_dir: Path
|
||||
):
|
||||
"""Falls back to diff-based detection when merge-tree output can't be parsed."""
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="main\n"),
|
||||
MagicMock(returncode=0, stdout="abc123\n"),
|
||||
MagicMock(returncode=0, stdout="0\n"),
|
||||
# merge-tree returns non-zero but no parseable output
|
||||
MagicMock(returncode=1, stdout="", stderr=""),
|
||||
# Fallback: diff from merge-base to main (empty to trigger fallback behavior)
|
||||
MagicMock(returncode=0, stdout=""),
|
||||
# Fallback: diff from merge-base to spec (empty)
|
||||
MagicMock(returncode=0, stdout=""),
|
||||
]
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
# With empty diffs, should have no conflicts
|
||||
assert result["conflicting_files"] == []
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_exception_during_conflict_check(self, mock_run, mock_project_dir: Path):
|
||||
"""Handles exceptions during conflict check."""
|
||||
mock_run.side_effect = Exception("Git command failed")
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
# Should return default result
|
||||
assert result["has_conflicts"] is False
|
||||
assert result["conflicting_files"] == []
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_filters_auto_claude_files_from_conflicts(
|
||||
self, mock_run, mock_project_dir: Path
|
||||
):
|
||||
"""Filters .auto-claude files from conflict list."""
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="main\n"),
|
||||
MagicMock(returncode=0, stdout="abc123\n"),
|
||||
MagicMock(returncode=0, stdout="0\n"),
|
||||
# Fallback diffs
|
||||
MagicMock(returncode=0, stdout=".auto-claude/config.json\nnormal_file.txt\n"),
|
||||
MagicMock(returncode=0, stdout=".auto-claude/config.json\nnormal_file.txt\n"),
|
||||
]
|
||||
|
||||
result = workspace_commands._check_git_merge_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="main"
|
||||
)
|
||||
|
||||
# .auto-claude files should be filtered out
|
||||
assert ".auto-claude/config.json" not in result["conflicting_files"]
|
||||
if result["conflicting_files"]:
|
||||
assert all(".auto-claude" not in f for f in result["conflicting_files"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_create_pr_command() - EDGE CASES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestDetectParallelTaskConflicts:
|
||||
"""Tests for _detect_parallel_task_conflicts function."""
|
||||
|
||||
def test_no_active_other_tasks(self, mock_project_dir: Path):
|
||||
"""Returns empty list when no other active tasks."""
|
||||
with patch("merge.MergeOrchestrator") as mock_orchestrator_class:
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.evolution_tracker.get_active_tasks.return_value = {
|
||||
TEST_SPEC_NAME
|
||||
}
|
||||
mock_orchestrator_class.return_value = mock_orchestrator
|
||||
|
||||
result = workspace_commands._detect_parallel_task_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, ["file1.txt"]
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_detects_file_overlap(self, mock_project_dir: Path):
|
||||
"""Detects when other tasks modify same files."""
|
||||
with patch("merge.MergeOrchestrator") as mock_orchestrator_class:
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.evolution_tracker.get_active_tasks.return_value = {
|
||||
TEST_SPEC_NAME, "002-other-spec"
|
||||
}
|
||||
mock_orchestrator.evolution_tracker.get_files_modified_by_tasks.return_value = {
|
||||
"file1.txt": ["002-other-spec"]
|
||||
}
|
||||
mock_orchestrator_class.return_value = mock_orchestrator
|
||||
|
||||
result = workspace_commands._detect_parallel_task_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, ["file1.txt", "file2.txt"]
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["file"] == "file1.txt"
|
||||
assert TEST_SPEC_NAME in result[0]["tasks"]
|
||||
assert "002-other-spec" in result[0]["tasks"]
|
||||
|
||||
def test_no_file_overlap(self, mock_project_dir: Path):
|
||||
"""Returns empty when no file overlap."""
|
||||
with patch("merge.MergeOrchestrator") as mock_orchestrator_class:
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.evolution_tracker.get_active_tasks.return_value = {
|
||||
TEST_SPEC_NAME, "002-other-spec"
|
||||
}
|
||||
mock_orchestrator.evolution_tracker.get_files_modified_by_tasks.return_value = {
|
||||
"other_file.txt": ["002-other-spec"]
|
||||
}
|
||||
mock_orchestrator_class.return_value = mock_orchestrator
|
||||
|
||||
result = workspace_commands._detect_parallel_task_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, ["file1.txt", "file2.txt"]
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_multiple_tasks_same_file(self, mock_project_dir: Path):
|
||||
"""Detects multiple tasks modifying same file."""
|
||||
with patch("merge.MergeOrchestrator") as mock_orchestrator_class:
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.evolution_tracker.get_active_tasks.return_value = {
|
||||
TEST_SPEC_NAME, "002-other-spec", "003-third-spec"
|
||||
}
|
||||
mock_orchestrator.evolution_tracker.get_files_modified_by_tasks.return_value = {
|
||||
"file1.txt": ["002-other-spec", "003-third-spec"]
|
||||
}
|
||||
mock_orchestrator_class.return_value = mock_orchestrator
|
||||
|
||||
result = workspace_commands._detect_parallel_task_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, ["file1.txt"]
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0]["tasks"]) == 3 # Current + 2 other tasks
|
||||
|
||||
def test_exception_returns_empty(self, mock_project_dir: Path):
|
||||
"""Returns empty list on exception."""
|
||||
with patch("merge.MergeOrchestrator", side_effect=Exception("Test error")):
|
||||
result = workspace_commands._detect_parallel_task_conflicts(
|
||||
mock_project_dir, TEST_SPEC_NAME, ["file1.txt"]
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_worktree_base_branch()
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Workspace Merge/Review/Discard Commands
|
||||
=====================================================
|
||||
|
||||
Tests the workspace_commands.py module functionality including:
|
||||
- handle_merge_command()
|
||||
- handle_review_command()
|
||||
- handle_discard_command()
|
||||
- handle_list_worktrees_command()
|
||||
- handle_cleanup_worktrees_command()
|
||||
- handle_merge_preview_command()
|
||||
- handle_create_pr_command()
|
||||
- _detect_default_branch()
|
||||
- _get_changed_files_from_git()
|
||||
- _check_git_merge_conflicts()
|
||||
- _detect_conflict_scenario()
|
||||
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the module under test
|
||||
from cli import workspace_commands
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
TEST_SPEC_NAME = "001-test-spec"
|
||||
TEST_SPEC_BRANCH = f"auto-claude/{TEST_SPEC_NAME}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_default_branch()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleMergeCommand:
|
||||
"""Tests for handle_merge_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.merge_existing_build")
|
||||
def test_merge_success(self, mock_merge, mock_project_dir: Path):
|
||||
"""Successful merge returns True."""
|
||||
mock_merge.return_value = True
|
||||
|
||||
result = workspace_commands.handle_merge_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_merge.assert_called_once_with(
|
||||
mock_project_dir, TEST_SPEC_NAME, no_commit=False, base_branch=None
|
||||
)
|
||||
|
||||
@patch("cli.workspace_commands.merge_existing_build")
|
||||
def test_merge_failure(self, mock_merge, mock_project_dir: Path):
|
||||
"""Failed merge returns False."""
|
||||
mock_merge.return_value = False
|
||||
|
||||
result = workspace_commands.handle_merge_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch("cli.workspace_commands.merge_existing_build")
|
||||
def test_merge_with_no_commit(self, mock_merge, mock_project_dir: Path):
|
||||
"""Merge with no_commit flag."""
|
||||
mock_merge.return_value = True
|
||||
|
||||
result = workspace_commands.handle_merge_command(
|
||||
mock_project_dir, TEST_SPEC_NAME, no_commit=True
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_merge.assert_called_once_with(
|
||||
mock_project_dir, TEST_SPEC_NAME, no_commit=True, base_branch=None
|
||||
)
|
||||
|
||||
@patch("cli.workspace_commands.merge_existing_build")
|
||||
@patch("cli.workspace_commands._generate_and_save_commit_message")
|
||||
def test_no_commit_generates_message(
|
||||
self, mock_generate, mock_merge, mock_project_dir: Path
|
||||
):
|
||||
"""No-commit mode generates commit message."""
|
||||
mock_merge.return_value = True
|
||||
|
||||
workspace_commands.handle_merge_command(
|
||||
mock_project_dir, TEST_SPEC_NAME, no_commit=True
|
||||
)
|
||||
|
||||
mock_generate.assert_called_once_with(mock_project_dir, TEST_SPEC_NAME)
|
||||
|
||||
@patch("cli.workspace_commands.merge_existing_build")
|
||||
def test_merge_with_base_branch(self, mock_merge, mock_project_dir: Path):
|
||||
"""Merge with specified base branch."""
|
||||
mock_merge.return_value = True
|
||||
|
||||
result = workspace_commands.handle_merge_command(
|
||||
mock_project_dir, TEST_SPEC_NAME, base_branch="develop"
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_merge.assert_called_once_with(
|
||||
mock_project_dir, TEST_SPEC_NAME, no_commit=False, base_branch="develop"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_review_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleReviewCommand:
|
||||
"""Tests for handle_review_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.review_existing_build")
|
||||
def test_review_calls_function(self, mock_review, mock_project_dir: Path):
|
||||
"""Review command calls review_existing_build."""
|
||||
workspace_commands.handle_review_command(mock_project_dir, TEST_SPEC_NAME)
|
||||
|
||||
mock_review.assert_called_once_with(mock_project_dir, TEST_SPEC_NAME)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_discard_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleDiscardCommand:
|
||||
"""Tests for handle_discard_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.discard_existing_build")
|
||||
def test_discard_calls_function(self, mock_discard, mock_project_dir: Path):
|
||||
"""Discard command calls discard_existing_build."""
|
||||
workspace_commands.handle_discard_command(mock_project_dir, TEST_SPEC_NAME)
|
||||
|
||||
mock_discard.assert_called_once_with(mock_project_dir, TEST_SPEC_NAME)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_list_worktrees_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleMergePreviewCommand:
|
||||
"""Tests for handle_merge_preview_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
def test_no_worktree_returns_error(self, mock_get, mock_project_dir: Path):
|
||||
"""Returns error when no worktree exists."""
|
||||
mock_get.return_value = None
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No existing build found" in result["error"]
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
def test_successful_preview(
|
||||
self,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Successful preview returns correct structure."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["file1.txt", "file2.txt"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["files"] == ["file1.txt", "file2.txt"]
|
||||
assert result["conflicts"] == []
|
||||
assert result["summary"]["totalFiles"] == 2
|
||||
assert result["summary"]["totalConflicts"] == 0
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
def test_preview_with_git_conflicts(
|
||||
self,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Preview detects git conflicts."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["file1.txt"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": True,
|
||||
"conflicting_files": ["file1.txt"],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["gitConflicts"]["hasConflicts"] is True
|
||||
assert result["gitConflicts"]["conflictingFiles"] == ["file1.txt"]
|
||||
assert len(result["conflicts"]) == 1
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
def test_preview_with_parallel_conflicts(
|
||||
self,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Preview detects parallel task conflicts."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["file1.txt"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
mock_parallel.return_value = [
|
||||
{"file": "file1.txt", "tasks": [TEST_SPEC_NAME, "002-other-spec"]}
|
||||
]
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["conflicts"]) == 1
|
||||
assert result["conflicts"][0]["type"] == "parallel"
|
||||
assert result["conflicts"][0]["file"] == "file1.txt"
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
def test_preview_with_lock_file_excluded(
|
||||
self,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Preview excludes lock files from conflicts."""
|
||||
from core.workspace.git_utils import is_lock_file
|
||||
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["package-lock.json", "file1.txt"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": True,
|
||||
"conflicting_files": ["package-lock.json"],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
# Lock files should be excluded
|
||||
assert result["gitConflicts"]["hasConflicts"] is False
|
||||
assert "package-lock.json" in result["lockFilesExcluded"]
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
def test_preview_exception_returns_error(
|
||||
self,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Exception during preview returns error result."""
|
||||
mock_get.side_effect = Exception("Test error")
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_create_pr_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestMergePreviewPathMapping:
|
||||
"""Tests for path mapping and rename detection in merge preview."""
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
@patch("cli.workspace_commands.get_merge_base")
|
||||
@patch("cli.workspace_commands.detect_file_renames")
|
||||
@patch("cli.workspace_commands.apply_path_mapping")
|
||||
@patch("cli.workspace_commands.get_file_content_from_ref")
|
||||
def test_detects_file_renames_and_path_mappings(
|
||||
self,
|
||||
mock_get_content,
|
||||
mock_apply_mapping,
|
||||
mock_detect_renames,
|
||||
mock_get_merge_base,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Detects file renames and creates AI merge entries for renamed files."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["old_path/file.py"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": True,
|
||||
"commits_behind": 5,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
mock_get_merge_base.return_value = "abc123"
|
||||
mock_detect_renames.return_value = {"old_path/file.py": "new_path/file.py"}
|
||||
mock_apply_mapping.side_effect = lambda x, m: m.get(x, x)
|
||||
mock_get_content.side_effect = [
|
||||
"worktree content",
|
||||
"target content",
|
||||
]
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["gitConflicts"]["totalRenames"] == 1
|
||||
assert len(result["gitConflicts"]["pathMappedAIMerges"]) == 1
|
||||
assert result["gitConflicts"]["pathMappedAIMerges"][0]["oldPath"] == "old_path/file.py"
|
||||
assert result["gitConflicts"]["pathMappedAIMerges"][0]["newPath"] == "new_path/file.py"
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
def test_no_path_mapping_when_no_rebase_needed(
|
||||
self,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Skips path mapping detection when no rebase is needed."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["file.py"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False, # No rebase needed
|
||||
"commits_behind": 0,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["gitConflicts"]["totalRenames"] == 0
|
||||
assert len(result["gitConflicts"]["pathMappedAIMerges"]) == 0
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
@patch("cli.workspace_commands.get_merge_base")
|
||||
def test_no_merge_base_returns_no_path_mappings(
|
||||
self,
|
||||
mock_get_merge_base,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Handles no merge base gracefully."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["file.py"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": True,
|
||||
"commits_behind": 5,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
mock_get_merge_base.return_value = None # No merge base
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["gitConflicts"]["totalRenames"] == 0
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands._detect_default_branch")
|
||||
@patch("cli.workspace_commands._get_changed_files_from_git")
|
||||
@patch("cli.workspace_commands._check_git_merge_conflicts")
|
||||
@patch("cli.workspace_commands._detect_parallel_task_conflicts")
|
||||
@patch("cli.workspace_commands.get_merge_base")
|
||||
@patch("cli.workspace_commands.detect_file_renames")
|
||||
@patch("cli.workspace_commands.apply_path_mapping")
|
||||
@patch("cli.workspace_commands.get_file_content_from_ref")
|
||||
def test_skips_files_without_both_contents(
|
||||
self,
|
||||
mock_get_content,
|
||||
mock_apply_mapping,
|
||||
mock_detect_renames,
|
||||
mock_get_merge_base,
|
||||
mock_parallel,
|
||||
mock_git_conflicts,
|
||||
mock_changed_files,
|
||||
mock_default_branch,
|
||||
mock_get,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Skips files when content cannot be retrieved from both refs."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_default_branch.return_value = "main"
|
||||
mock_changed_files.return_value = ["old_path/file.py"]
|
||||
mock_git_conflicts.return_value = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": True,
|
||||
"commits_behind": 5,
|
||||
"base_branch": "main",
|
||||
"spec_branch": TEST_SPEC_BRANCH,
|
||||
}
|
||||
mock_parallel.return_value = []
|
||||
mock_get_merge_base.return_value = "abc123"
|
||||
mock_detect_renames.return_value = {"old_path/file.py": "new_path/file.py"}
|
||||
mock_apply_mapping.side_effect = lambda x, m: m.get(x, x)
|
||||
# Only one content available, not both
|
||||
mock_get_content.side_effect = ["worktree content", None]
|
||||
|
||||
result = workspace_commands.handle_merge_preview_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
# Should not add to path mapped merges since both contents aren't available
|
||||
assert len(result["gitConflicts"]["pathMappedAIMerges"]) == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_default_branch() - FALLBACK
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestGenerateAndSaveCommitMessageEdgeCases:
|
||||
"""Tests for edge cases in commit message generation."""
|
||||
|
||||
@patch("commit_message.generate_commit_message_sync")
|
||||
@patch("subprocess.run")
|
||||
def test_git_diff_failure_returns_empty_summary(
|
||||
self, mock_run, mock_generate, mock_project_dir: Path, workspace_spec_dir: Path
|
||||
):
|
||||
"""Handles git diff failure gracefully."""
|
||||
mock_run.side_effect = Exception("Git command failed")
|
||||
mock_generate.return_value = "Test commit message"
|
||||
|
||||
workspace_commands._generate_and_save_commit_message(mock_project_dir, TEST_SPEC_NAME)
|
||||
|
||||
# Should still call generate_commit_message_sync with empty summary
|
||||
mock_generate.assert_called_once()
|
||||
call_args = mock_generate.call_args
|
||||
assert call_args.kwargs["diff_summary"] == ""
|
||||
assert call_args.kwargs["files_changed"] == []
|
||||
|
||||
@patch("commit_message.generate_commit_message_sync")
|
||||
def test_spec_dir_not_found_logs_warning(
|
||||
self, mock_generate, mock_project_dir: Path
|
||||
):
|
||||
"""Logs warning when spec directory not found."""
|
||||
mock_generate.return_value = "Test commit message"
|
||||
# Use non-existent spec name
|
||||
workspace_commands._generate_and_save_commit_message(
|
||||
mock_project_dir, "nonexistent-spec"
|
||||
)
|
||||
|
||||
# Should not crash, just handle gracefully
|
||||
|
||||
@patch("commit_message.generate_commit_message_sync", return_value=None)
|
||||
def test_no_commit_message_generated_logs_warning(
|
||||
self, mock_generate, mock_project_dir: Path, workspace_spec_dir: Path
|
||||
):
|
||||
"""Logs warning when no commit message is generated."""
|
||||
workspace_commands._generate_and_save_commit_message(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should handle None return value gracefully
|
||||
|
||||
@patch("commit_message.generate_commit_message_sync", side_effect=ImportError)
|
||||
def test_import_error_logs_warning(
|
||||
self, mock_generate, mock_project_dir: Path, workspace_spec_dir: Path
|
||||
):
|
||||
"""Logs warning when commit_message module import fails."""
|
||||
workspace_commands._generate_and_save_commit_message(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should handle ImportError gracefully
|
||||
|
||||
@patch("commit_message.generate_commit_message_sync", side_effect=Exception("Generation failed"))
|
||||
def test_generation_exception_logs_warning(
|
||||
self, mock_generate, mock_project_dir: Path, workspace_spec_dir: Path
|
||||
):
|
||||
"""Logs warning when commit message generation raises exception."""
|
||||
workspace_commands._generate_and_save_commit_message(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should handle exception gracefully
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_conflict_scenario() - EDGE CASES
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Workspace PR Commands
|
||||
===================================
|
||||
|
||||
Tests handle_create_pr_command() functionality.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the module under test
|
||||
from cli import workspace_commands
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
TEST_SPEC_NAME = "001-test-spec"
|
||||
TEST_SPEC_BRANCH = f"auto-claude/{TEST_SPEC_NAME}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_default_branch()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleCreatePRCommand:
|
||||
"""Tests for handle_create_pr_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
def test_no_worktree_returns_error(
|
||||
self, mock_get, mock_project_dir: Path, capsys
|
||||
):
|
||||
"""Returns error when no worktree exists."""
|
||||
mock_get.return_value = None
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No build found" in result["error"]
|
||||
captured = capsys.readouterr()
|
||||
assert "No build found" in captured.out
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_successful_pr_creation(
|
||||
self,
|
||||
mock_banner,
|
||||
mock_get,
|
||||
mock_manager_class,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
capsys,
|
||||
):
|
||||
"""Successfully creates PR."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "main"
|
||||
mock_manager_instance.push_and_create_pr.return_value = {
|
||||
"success": True,
|
||||
"pr_url": "https://github.com/test/repo/pull/1",
|
||||
"already_exists": False,
|
||||
}
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["pr_url"] == "https://github.com/test/repo/pull/1"
|
||||
captured = capsys.readouterr()
|
||||
assert "PR created successfully" in captured.out
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_pr_already_exists(
|
||||
self,
|
||||
mock_banner,
|
||||
mock_get,
|
||||
mock_manager_class,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
capsys,
|
||||
):
|
||||
"""Handles existing PR."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "main"
|
||||
mock_manager_instance.push_and_create_pr.return_value = {
|
||||
"success": True,
|
||||
"pr_url": "https://github.com/test/repo/pull/1",
|
||||
"already_exists": True,
|
||||
}
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["already_exists"] is True
|
||||
captured = capsys.readouterr()
|
||||
assert "PR already exists" in captured.out
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_pr_creation_failure(
|
||||
self,
|
||||
mock_banner,
|
||||
mock_get,
|
||||
mock_manager_class,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
capsys,
|
||||
):
|
||||
"""Handles PR creation failure."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "main"
|
||||
mock_manager_instance.push_and_create_pr.return_value = {
|
||||
"success": False,
|
||||
"error": "Authentication failed",
|
||||
}
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Authentication failed"
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_pr_with_custom_options(
|
||||
self,
|
||||
mock_banner,
|
||||
mock_get,
|
||||
mock_manager_class,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Creates PR with custom title and target branch."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "develop"
|
||||
mock_manager_instance.push_and_create_pr.return_value = {
|
||||
"success": True,
|
||||
"pr_url": "https://github.com/test/repo/pull/1",
|
||||
}
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir,
|
||||
TEST_SPEC_NAME,
|
||||
target_branch="develop",
|
||||
title="Custom Title",
|
||||
draft=True,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_manager_instance.push_and_create_pr.assert_called_once_with(
|
||||
spec_name=TEST_SPEC_NAME,
|
||||
target_branch="develop",
|
||||
title="Custom Title",
|
||||
draft=True,
|
||||
)
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_pr_creation_exception_handling(
|
||||
self,
|
||||
mock_banner,
|
||||
mock_get,
|
||||
mock_manager_class,
|
||||
mock_project_dir: Path,
|
||||
mock_worktree_path: Path,
|
||||
):
|
||||
"""Handles exceptions during PR creation."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "main"
|
||||
mock_manager_instance.push_and_create_pr.side_effect = Exception("Network error")
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Network error" in result["error"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _check_git_merge_conflicts()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleCreatePREdgeCases:
|
||||
"""Tests for edge cases in PR creation."""
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_pr_created_without_url(
|
||||
self, mock_banner, mock_get, mock_manager_class, mock_project_dir: Path,
|
||||
mock_worktree_path: Path, capsys
|
||||
):
|
||||
"""Handles successful PR creation with no URL returned."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "main"
|
||||
mock_manager_instance.push_and_create_pr.return_value = {
|
||||
"success": True,
|
||||
"pr_url": None, # No URL returned
|
||||
"already_exists": False,
|
||||
}
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
captured = capsys.readouterr()
|
||||
assert "Check GitHub for the PR URL" in captured.out
|
||||
|
||||
@patch("core.worktree.WorktreeManager")
|
||||
@patch("cli.workspace_commands.get_existing_build_worktree")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_push_failed_error(
|
||||
self, mock_banner, mock_get, mock_manager_class, mock_project_dir: Path,
|
||||
mock_worktree_path: Path
|
||||
):
|
||||
"""Handles push failure."""
|
||||
mock_get.return_value = mock_worktree_path
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.base_branch = "main"
|
||||
mock_manager_instance.push_and_create_pr.return_value = {
|
||||
"success": False,
|
||||
"error": "Push failed: remote rejected",
|
||||
"pushed": False,
|
||||
}
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.handle_create_pr_command(
|
||||
mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Push failed" in result["error"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_merge_preview_command() - PATH MAPPING
|
||||
# =============================================================================
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for CLI Workspace Worktree Management
|
||||
===========================================
|
||||
|
||||
Tests worktree management functions:
|
||||
- handle_list_worktrees_command()
|
||||
- handle_cleanup_worktrees_command()
|
||||
- _detect_worktree_base_branch()
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the module under test
|
||||
from cli import workspace_commands
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
TEST_SPEC_NAME = "001-test-spec"
|
||||
TEST_SPEC_BRANCH = f"auto-claude/{TEST_SPEC_NAME}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _detect_default_branch()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleListWorktreesCommand:
|
||||
"""Tests for handle_list_worktrees_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.list_all_worktrees")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_list_with_no_worktrees(self, mock_banner, mock_list, mock_project_dir: Path, capsys):
|
||||
"""Lists worktrees when none exist."""
|
||||
mock_list.return_value = []
|
||||
|
||||
workspace_commands.handle_list_worktrees_command(mock_project_dir)
|
||||
|
||||
mock_banner.assert_called_once()
|
||||
captured = capsys.readouterr()
|
||||
assert "No worktrees found" in captured.out
|
||||
|
||||
@patch("cli.workspace_commands.list_all_worktrees")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_list_with_worktrees(self, mock_banner, mock_list, mock_project_dir: Path, capsys):
|
||||
"""Lists existing worktrees."""
|
||||
from typing import NamedTuple
|
||||
|
||||
# Create a mock worktree
|
||||
MockWorktree = NamedTuple(
|
||||
"MockWorktree",
|
||||
[("spec_name", str), ("branch", str), ("path", Path),
|
||||
("commit_count", int), ("files_changed", int)]
|
||||
)
|
||||
mock_worktree = MockWorktree(
|
||||
spec_name=TEST_SPEC_NAME,
|
||||
branch=TEST_SPEC_BRANCH,
|
||||
path=Path("/test/path"),
|
||||
commit_count=5,
|
||||
files_changed=10
|
||||
)
|
||||
mock_list.return_value = [mock_worktree]
|
||||
|
||||
workspace_commands.handle_list_worktrees_command(mock_project_dir)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert TEST_SPEC_NAME in captured.out
|
||||
assert TEST_SPEC_BRANCH in captured.out
|
||||
assert "5" in captured.out
|
||||
assert "10" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_cleanup_worktrees_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestHandleCleanupWorktreesCommand:
|
||||
"""Tests for handle_cleanup_worktrees_command function."""
|
||||
|
||||
@patch("cli.workspace_commands.cleanup_all_worktrees")
|
||||
@patch("cli.workspace_commands.print_banner")
|
||||
def test_cleanup_calls_function(self, mock_banner, mock_cleanup, mock_project_dir: Path):
|
||||
"""Cleanup command calls cleanup_all_worktrees."""
|
||||
workspace_commands.handle_cleanup_worktrees_command(mock_project_dir)
|
||||
|
||||
mock_banner.assert_called_once()
|
||||
mock_cleanup.assert_called_once_with(mock_project_dir, confirm=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR handle_merge_preview_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestCleanupOldWorktreesCommand:
|
||||
"""Tests for cleanup_old_worktrees_command function."""
|
||||
|
||||
def test_successful_cleanup(self, mock_project_dir: Path):
|
||||
"""Successfully cleans up old worktrees."""
|
||||
with patch("cli.workspace_commands.WorktreeManager") as mock_manager_class:
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.cleanup_old_worktrees.return_value = (["worktree1"], [])
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.cleanup_old_worktrees_command(
|
||||
mock_project_dir, days=30, dry_run=False
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["removed"] == ["worktree1"]
|
||||
assert result["failed"] == []
|
||||
assert result["days_threshold"] == 30
|
||||
assert result["dry_run"] is False
|
||||
|
||||
def test_dry_run_mode(self, mock_project_dir: Path):
|
||||
"""Dry run mode doesn't actually remove worktrees."""
|
||||
with patch("cli.workspace_commands.WorktreeManager") as mock_manager_class:
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.cleanup_old_worktrees.return_value = (["worktree1"], [])
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.cleanup_old_worktrees_command(
|
||||
mock_project_dir, days=30, dry_run=True
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["dry_run"] is True
|
||||
mock_manager_instance.cleanup_old_worktrees.assert_called_once_with(
|
||||
days_threshold=30, dry_run=True
|
||||
)
|
||||
|
||||
def test_custom_days_threshold(self, mock_project_dir: Path):
|
||||
"""Uses custom days threshold."""
|
||||
with patch("cli.workspace_commands.WorktreeManager") as mock_manager_class:
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.cleanup_old_worktrees.return_value = ([], [])
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.cleanup_old_worktrees_command(
|
||||
mock_project_dir, days=7, dry_run=False
|
||||
)
|
||||
|
||||
assert result["days_threshold"] == 7
|
||||
mock_manager_instance.cleanup_old_worktrees.assert_called_once_with(
|
||||
days_threshold=7, dry_run=False
|
||||
)
|
||||
|
||||
def test_exception_handling(self, mock_project_dir: Path):
|
||||
"""Handles exceptions gracefully."""
|
||||
with patch("cli.workspace_commands.WorktreeManager", side_effect=Exception("Cleanup failed")):
|
||||
result = workspace_commands.cleanup_old_worktrees_command(
|
||||
mock_project_dir, days=30
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR worktree_summary_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestWorktreeSummaryCommand:
|
||||
"""Tests for worktree_summary_command function."""
|
||||
|
||||
def test_successful_summary(self, mock_project_dir: Path):
|
||||
"""Successfully generates worktree summary."""
|
||||
from typing import NamedTuple
|
||||
|
||||
MockWorktreeInfo = NamedTuple(
|
||||
"MockWorktreeInfo",
|
||||
[
|
||||
("spec_name", str),
|
||||
("days_since_last_commit", int | None),
|
||||
("commit_count", int),
|
||||
],
|
||||
)
|
||||
|
||||
with patch("cli.workspace_commands.WorktreeManager") as mock_manager_class:
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.list_all_worktrees.return_value = [
|
||||
MockWorktreeInfo(spec_name="001", days_since_last_commit=5, commit_count=3),
|
||||
MockWorktreeInfo(spec_name="002", days_since_last_commit=40, commit_count=1),
|
||||
]
|
||||
mock_manager_instance.get_worktree_count_warning.return_value = "Warning: Many worktrees"
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.worktree_summary_command(mock_project_dir)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["total_worktrees"] == 2
|
||||
assert len(result["categories"]["recent"]) == 1
|
||||
assert len(result["categories"]["month_old"]) == 1 # 40 days falls in month_old
|
||||
assert result["warning"] == "Warning: Many worktrees"
|
||||
|
||||
def test_categorizes_by_age(self, mock_project_dir: Path):
|
||||
"""Categorizes worktrees by age correctly."""
|
||||
from typing import NamedTuple
|
||||
|
||||
MockWorktreeInfo = NamedTuple(
|
||||
"MockWorktreeInfo",
|
||||
[
|
||||
("spec_name", str),
|
||||
("days_since_last_commit", int | None),
|
||||
("commit_count", int),
|
||||
],
|
||||
)
|
||||
|
||||
with patch("cli.workspace_commands.WorktreeManager") as mock_manager_class:
|
||||
mock_manager_instance = MagicMock()
|
||||
mock_manager_instance.list_all_worktrees.return_value = [
|
||||
MockWorktreeInfo(spec_name="001", days_since_last_commit=3, commit_count=1),
|
||||
MockWorktreeInfo(spec_name="002", days_since_last_commit=15, commit_count=1),
|
||||
MockWorktreeInfo(spec_name="003", days_since_last_commit=45, commit_count=1),
|
||||
MockWorktreeInfo(spec_name="004", days_since_last_commit=100, commit_count=1),
|
||||
MockWorktreeInfo(spec_name="005", days_since_last_commit=None, commit_count=1),
|
||||
]
|
||||
mock_manager_instance.get_worktree_count_warning.return_value = None
|
||||
mock_manager_class.return_value = mock_manager_instance
|
||||
|
||||
result = workspace_commands.worktree_summary_command(mock_project_dir)
|
||||
|
||||
assert len(result["categories"]["recent"]) == 1 # < 7 days
|
||||
assert len(result["categories"]["week_old"]) == 1 # 7-29 days (changed to 15)
|
||||
assert len(result["categories"]["month_old"]) == 1 # 30-89 days
|
||||
assert len(result["categories"]["very_old"]) == 1 # >= 90 days
|
||||
assert len(result["categories"]["unknown_age"]) == 1 # None
|
||||
|
||||
def test_exception_handling(self, mock_project_dir: Path):
|
||||
"""Handles exceptions gracefully."""
|
||||
with patch("cli.workspace_commands.WorktreeManager", side_effect=Exception("Summary failed")):
|
||||
result = workspace_commands.worktree_summary_command(mock_project_dir)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["total_worktrees"] == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR _get_changed_files_from_git() - FALLBACK BRANCHES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestDetectWorktreeBaseBranch:
|
||||
"""Tests for _detect_worktree_base_branch function."""
|
||||
|
||||
def test_reads_from_config_file(self, temp_git_repo: Path, mock_worktree_path: Path):
|
||||
"""Reads base branch from worktree config file."""
|
||||
config_dir = mock_worktree_path / ".auto-claude"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "worktree-config.json"
|
||||
config_file.write_text(json.dumps({"base_branch": "develop"}), encoding="utf-8")
|
||||
|
||||
result = workspace_commands._detect_worktree_base_branch(
|
||||
temp_git_repo, mock_worktree_path, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result == "develop"
|
||||
|
||||
def test_no_config_returns_none(self, temp_git_repo: Path, mock_worktree_path: Path):
|
||||
"""Returns None when no config file exists."""
|
||||
result = workspace_commands._detect_worktree_base_branch(
|
||||
temp_git_repo, mock_worktree_path, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should return None if can't detect
|
||||
assert result is None or result in ["main", "master", "develop"]
|
||||
|
||||
def test_invalid_config_falls_back(self, temp_git_repo: Path, mock_worktree_path: Path):
|
||||
"""Handles invalid config file gracefully."""
|
||||
config_dir = mock_worktree_path / ".auto-claude"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "worktree-config.json"
|
||||
config_file.write_text("invalid json", encoding="utf-8")
|
||||
|
||||
result = workspace_commands._detect_worktree_base_branch(
|
||||
temp_git_repo, mock_worktree_path, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should not crash, return None or detected branch
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR cleanup_old_worktrees_command()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
class TestDetectWorktreeBaseBranchDetection:
|
||||
"""Tests for branch detection logic in _detect_worktree_base_branch."""
|
||||
|
||||
def test_detects_from_develop_branch(self, temp_git_repo: Path):
|
||||
"""Detects develop branch when it has fewest commits ahead."""
|
||||
# Create develop branch
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "develop"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
# Create spec branch from develop
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", TEST_SPEC_BRANCH],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "main"],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
result = workspace_commands._detect_worktree_base_branch(
|
||||
temp_git_repo, temp_git_repo, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should detect develop as base branch
|
||||
assert result in ["develop", "main"]
|
||||
|
||||
def test_returns_none_when_no_branches_match(self, mock_project_dir: Path):
|
||||
"""Returns None when no candidate branches exist."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
# No branches exist
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
|
||||
result = workspace_commands._detect_worktree_base_branch(
|
||||
mock_project_dir, mock_project_dir, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_handles_merge_base_failure_during_detection(
|
||||
self, mock_run, mock_project_dir: Path, mock_worktree_path: Path
|
||||
):
|
||||
"""Handles merge-base command failure gracefully."""
|
||||
# Branch exists but merge-base fails
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0), # Branch check passes
|
||||
MagicMock(returncode=1), # merge-base fails
|
||||
]
|
||||
|
||||
result = workspace_commands._detect_worktree_base_branch(
|
||||
mock_project_dir, mock_worktree_path, TEST_SPEC_NAME
|
||||
)
|
||||
|
||||
# Should continue checking other branches or return None
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR DEBUG FUNCTION FALLBACKS
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Conftest Fixtures - Validate Mock Fixtures Match Real Modules
|
||||
==================================================================
|
||||
|
||||
Tests to ensure mock fixtures in conftest.py stay in sync with the real modules
|
||||
they mock. This catches drift when the real module changes but the mock is not updated.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add apps/backend to path so we can import real modules
|
||||
backend_path = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(backend_path) not in sys.path:
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
|
||||
class TestMockIconsSync:
|
||||
"""Tests to validate mock_ui_icons fixture matches real Icons class."""
|
||||
|
||||
def test_mock_icons_has_all_real_icon_constants(self, mock_ui_icons):
|
||||
"""
|
||||
Verify MockIcons has all the same icon constants as the real Icons class.
|
||||
|
||||
This test catches when new icons are added to the real Icons class
|
||||
but the mock is not updated.
|
||||
"""
|
||||
from ui.icons import Icons
|
||||
|
||||
# Get all class attributes that are tuples (icon definitions)
|
||||
real_icons = {
|
||||
name: value
|
||||
for name, value in vars(Icons).items()
|
||||
if not name.startswith("_") and isinstance(value, tuple)
|
||||
}
|
||||
|
||||
mock_icons = {
|
||||
name: value
|
||||
for name, value in vars(mock_ui_icons).items()
|
||||
if not name.startswith("_") and isinstance(value, tuple)
|
||||
}
|
||||
|
||||
# Check for missing icons in mock
|
||||
missing_from_mock = set(real_icons.keys()) - set(mock_icons.keys())
|
||||
assert not missing_from_mock, (
|
||||
f"MockIcons is missing icons that exist in real Icons class: {missing_from_mock}. "
|
||||
f"Update the mock_ui_icons fixture in tests/conftest.py to include these icons."
|
||||
)
|
||||
|
||||
# Check for extra icons in mock (shouldn't happen but good to catch)
|
||||
extra_in_mock = set(mock_icons.keys()) - set(real_icons.keys())
|
||||
assert not extra_in_mock, (
|
||||
f"MockIcons has icons that don't exist in real Icons class: {extra_in_mock}. "
|
||||
f"Remove these from the mock_ui_icons fixture in tests/conftest.py."
|
||||
)
|
||||
|
||||
def test_mock_icons_values_match_real(self, mock_ui_icons):
|
||||
"""
|
||||
Verify MockIcons icon values match the real Icons class.
|
||||
|
||||
This test catches when icon tuples are changed in the real Icons class
|
||||
but the mock still has the old values.
|
||||
"""
|
||||
from ui.icons import Icons
|
||||
|
||||
# Get all class attributes that are tuples (icon definitions)
|
||||
real_icons = {
|
||||
name: value
|
||||
for name, value in vars(Icons).items()
|
||||
if not name.startswith("_") and isinstance(value, tuple)
|
||||
}
|
||||
|
||||
mock_icons = {
|
||||
name: value
|
||||
for name, value in vars(mock_ui_icons).items()
|
||||
if not name.startswith("_") and isinstance(value, tuple)
|
||||
}
|
||||
|
||||
# Compare values for icons that exist in both
|
||||
mismatches = []
|
||||
for name in real_icons:
|
||||
if name in mock_icons:
|
||||
if real_icons[name] != mock_icons[name]:
|
||||
mismatches.append(
|
||||
f"{name}: real={real_icons[name]}, mock={mock_icons[name]}"
|
||||
)
|
||||
|
||||
assert not mismatches, (
|
||||
f"MockIcons values don't match real Icons class:\n"
|
||||
+ "\n".join(mismatches)
|
||||
+ "\n\nUpdate the mock_ui_icons fixture in tests/conftest.py to match."
|
||||
)
|
||||
|
||||
|
||||
class TestMockUIModuleFullSync:
|
||||
"""Tests to validate mock_ui_module_full fixture matches real UI module."""
|
||||
|
||||
def test_mock_ui_module_has_icons_class(self, mock_ui_module_full):
|
||||
"""Verify mock UI module has Icons class."""
|
||||
assert hasattr(mock_ui_module_full, "Icons"), (
|
||||
"mock_ui_module_full is missing Icons class. "
|
||||
"Update the mock_ui_module_full fixture in tests/conftest.py."
|
||||
)
|
||||
|
||||
def test_mock_ui_module_has_menu_option_class(self, mock_ui_module_full):
|
||||
"""Verify mock UI module has MenuOption class."""
|
||||
assert hasattr(mock_ui_module_full, "MenuOption"), (
|
||||
"mock_ui_module_full is missing MenuOption class. "
|
||||
"Update the mock_ui_module_full fixture in tests/conftest.py."
|
||||
)
|
||||
|
||||
def test_mock_ui_module_has_required_functions(self, mock_ui_module_full):
|
||||
"""Verify mock UI module has all required functions."""
|
||||
required_functions = [
|
||||
"icon",
|
||||
"bold",
|
||||
"muted",
|
||||
"box",
|
||||
"print_status",
|
||||
"select_menu",
|
||||
"error",
|
||||
"success",
|
||||
"warning",
|
||||
"info",
|
||||
"highlight",
|
||||
]
|
||||
|
||||
missing = [fn for fn in required_functions if not hasattr(mock_ui_module_full, fn)]
|
||||
assert not missing, (
|
||||
f"mock_ui_module_full is missing functions: {missing}. "
|
||||
f"Update the mock_ui_module_full fixture in tests/conftest.py."
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ This test suite verifies that:
|
||||
5. Provider field is correctly set to "github"
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -28,6 +29,19 @@ from worktree import PullRequestResult, WorktreeInfo, WorktreeManager
|
||||
class TestGitHubProviderDetection:
|
||||
"""Test that GitHub remotes are still detected correctly."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_git_env(self):
|
||||
"""Clear GIT_* environment variables to prevent worktree interference."""
|
||||
# Store original values
|
||||
git_vars = {k: v for k, v in os.environ.items() if k.startswith('GIT_')}
|
||||
# Clear GIT environment variables
|
||||
for k in list(git_vars.keys()):
|
||||
del os.environ[k]
|
||||
yield
|
||||
# Restore original values
|
||||
for k, v in git_vars.items():
|
||||
os.environ[k] = v
|
||||
|
||||
def test_github_https_detection(self, tmp_path):
|
||||
"""Test GitHub HTTPS URL detection."""
|
||||
repo_path = tmp_path / "test-repo"
|
||||
|
||||
@@ -20,6 +20,7 @@ Requirements:
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -99,12 +100,16 @@ def create_test_git_repo(repo_path: Path, remote_url: str) -> bool:
|
||||
try:
|
||||
repo_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clear GIT_* environment variables to prevent worktree interference
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
|
||||
|
||||
# Initialize git repo
|
||||
subprocess.run(
|
||||
["git", "init"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Configure git user for commits
|
||||
@@ -113,12 +118,14 @@ def create_test_git_repo(repo_path: Path, remote_url: str) -> bool:
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Disable GPG signing to prevent hangs in CI
|
||||
@@ -127,6 +134,7 @@ def create_test_git_repo(repo_path: Path, remote_url: str) -> bool:
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Add remote
|
||||
@@ -135,6 +143,7 @@ def create_test_git_repo(repo_path: Path, remote_url: str) -> bool:
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Create initial commit
|
||||
@@ -144,12 +153,14 @@ def create_test_git_repo(repo_path: Path, remote_url: str) -> bool:
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Initial commit"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for Graphiti memory integration."""
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
@@ -529,3 +530,252 @@ class TestGraphitiState:
|
||||
assert len(state.error_log) == 10
|
||||
assert "Error 5" in state.error_log[0]["error"]
|
||||
assert "Error 14" in state.error_log[-1]["error"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LADYBUGDB LOCK RETRY LOGIC TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestIsLockError:
|
||||
"""Tests for _is_lock_error lock detection function."""
|
||||
|
||||
def test_lock_file_error_detected(self):
|
||||
"""Detects lock + file pattern in error messages."""
|
||||
from integrations.graphiti.queries_pkg.client import _is_lock_error
|
||||
|
||||
assert _is_lock_error(Exception("Could not set lock on file")) is True
|
||||
|
||||
def test_lock_database_error_detected(self):
|
||||
"""Detects lock + database pattern in error messages."""
|
||||
from integrations.graphiti.queries_pkg.client import _is_lock_error
|
||||
|
||||
assert _is_lock_error(Exception("Database lock contention detected")) is True
|
||||
|
||||
def test_could_not_set_lock_detected(self):
|
||||
"""Detects 'could not set lock' pattern."""
|
||||
from integrations.graphiti.queries_pkg.client import _is_lock_error
|
||||
|
||||
assert _is_lock_error(Exception("could not set lock")) is True
|
||||
|
||||
def test_non_lock_error_not_detected(self):
|
||||
"""Non-lock errors are not detected as lock errors."""
|
||||
from integrations.graphiti.queries_pkg.client import _is_lock_error
|
||||
|
||||
assert _is_lock_error(Exception("Connection refused")) is False
|
||||
assert _is_lock_error(Exception("Timeout error")) is False
|
||||
assert _is_lock_error(Exception("Permission denied")) is False
|
||||
|
||||
def test_lock_without_file_or_database_not_detected(self):
|
||||
"""'lock' alone without 'file' or 'database' is not detected."""
|
||||
from integrations.graphiti.queries_pkg.client import _is_lock_error
|
||||
|
||||
# 'lock' without 'file' or 'database' and no 'could not set lock'
|
||||
assert _is_lock_error(Exception("Object is locked by user")) is False
|
||||
|
||||
|
||||
class TestBackoffWithJitter:
|
||||
"""Tests for _backoff_with_jitter calculation."""
|
||||
|
||||
def test_backoff_increases_with_attempt(self):
|
||||
"""Backoff time increases with attempt number."""
|
||||
from integrations.graphiti.queries_pkg.client import _backoff_with_jitter
|
||||
|
||||
# Run multiple times to account for jitter
|
||||
attempt_0_values = [_backoff_with_jitter(0) for _ in range(20)]
|
||||
attempt_3_values = [_backoff_with_jitter(3) for _ in range(20)]
|
||||
|
||||
avg_0 = sum(attempt_0_values) / len(attempt_0_values)
|
||||
avg_3 = sum(attempt_3_values) / len(attempt_3_values)
|
||||
|
||||
assert avg_3 > avg_0, "Higher attempts should have higher average backoff"
|
||||
|
||||
def test_backoff_is_positive(self):
|
||||
"""Backoff is always positive."""
|
||||
from integrations.graphiti.queries_pkg.client import _backoff_with_jitter
|
||||
|
||||
for attempt in range(10):
|
||||
for _ in range(10):
|
||||
assert _backoff_with_jitter(attempt) > 0
|
||||
|
||||
def test_backoff_capped_at_max(self):
|
||||
"""Backoff should not exceed MAX_BACKOFF_SECONDS + jitter."""
|
||||
from integrations.graphiti.queries_pkg.client import (
|
||||
JITTER_PERCENT,
|
||||
MAX_BACKOFF_SECONDS,
|
||||
_backoff_with_jitter,
|
||||
)
|
||||
|
||||
max_possible = MAX_BACKOFF_SECONDS * (1 + JITTER_PERCENT)
|
||||
for _ in range(50):
|
||||
val = _backoff_with_jitter(100) # Very high attempt
|
||||
assert val <= max_possible + 0.01, f"Backoff {val} exceeded max {max_possible}"
|
||||
|
||||
|
||||
class TestGraphitiClientRetryLogic:
|
||||
"""Tests for LadybugDB lock retry logic in GraphitiClient.initialize().
|
||||
|
||||
These tests exercise the retry loop behavior by mocking the modules
|
||||
that are imported locally inside initialize(). We patch at the source
|
||||
module level since the imports are local to the method.
|
||||
"""
|
||||
|
||||
def _make_config(self):
|
||||
"""Create a mock GraphitiConfig for testing."""
|
||||
config = MagicMock()
|
||||
config.llm_provider = "openai"
|
||||
config.embedder_provider = "openai"
|
||||
config.get_db_path.return_value = Path("/tmp/test-db")
|
||||
config.get_provider_summary.return_value = "openai/openai"
|
||||
return config
|
||||
|
||||
def _make_mock_providers(self):
|
||||
"""Create mock graphiti_providers module."""
|
||||
mock_providers = MagicMock()
|
||||
mock_providers.create_llm_client = MagicMock(return_value=MagicMock())
|
||||
mock_providers.create_embedder = MagicMock(return_value=MagicMock())
|
||||
mock_providers.ProviderError = type("ProviderError", (Exception,), {})
|
||||
mock_providers.ProviderNotInstalled = type(
|
||||
"ProviderNotInstalled", (mock_providers.ProviderError,), {}
|
||||
)
|
||||
return mock_providers
|
||||
|
||||
def _make_noop_sleep(self):
|
||||
"""Create an async no-op replacement for asyncio.sleep."""
|
||||
async def _noop_sleep(_delay):
|
||||
return
|
||||
|
||||
return _noop_sleep
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_retry_after_lock_error(self):
|
||||
"""Client retries and succeeds after transient lock error."""
|
||||
from integrations.graphiti.queries_pkg.client import GraphitiClient
|
||||
|
||||
config = self._make_config()
|
||||
client = GraphitiClient(config)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_create_driver(db=""):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise OSError("Could not set lock on file /tmp/test-db")
|
||||
return MagicMock()
|
||||
|
||||
mock_graphiti_instance = MagicMock()
|
||||
|
||||
async def mock_build_indices():
|
||||
pass
|
||||
|
||||
mock_graphiti_instance.build_indices_and_constraints = mock_build_indices
|
||||
|
||||
mock_graphiti_cls = MagicMock(return_value=mock_graphiti_instance)
|
||||
mock_graphiti_core = MagicMock()
|
||||
mock_graphiti_core.Graphiti = mock_graphiti_cls
|
||||
|
||||
mock_kuzu_driver = MagicMock()
|
||||
mock_kuzu_driver.create_patched_kuzu_driver = mock_create_driver
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {
|
||||
"graphiti_core": mock_graphiti_core,
|
||||
"graphiti_providers": self._make_mock_providers(),
|
||||
"integrations.graphiti.queries_pkg.kuzu_driver_patched": mock_kuzu_driver,
|
||||
}),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client._apply_ladybug_monkeypatch",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client.asyncio.sleep",
|
||||
side_effect=self._make_noop_sleep(),
|
||||
),
|
||||
):
|
||||
result = await client.initialize()
|
||||
|
||||
assert call_count == 2, "Should have retried once after lock error"
|
||||
assert result is True, "Should succeed after retry"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exhausted_retries_returns_false(self):
|
||||
"""Client returns False after exhausting all retries on lock errors."""
|
||||
from integrations.graphiti.queries_pkg.client import (
|
||||
MAX_LOCK_RETRIES,
|
||||
GraphitiClient,
|
||||
)
|
||||
|
||||
config = self._make_config()
|
||||
client = GraphitiClient(config)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def always_lock_error(db=""):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise OSError("Could not set lock on database file")
|
||||
|
||||
mock_kuzu_driver = MagicMock()
|
||||
mock_kuzu_driver.create_patched_kuzu_driver = always_lock_error
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_providers": self._make_mock_providers(),
|
||||
"integrations.graphiti.queries_pkg.kuzu_driver_patched": mock_kuzu_driver,
|
||||
}),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client._apply_ladybug_monkeypatch",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client.capture_exception",
|
||||
),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client.asyncio.sleep",
|
||||
side_effect=self._make_noop_sleep(),
|
||||
),
|
||||
):
|
||||
result = await client.initialize()
|
||||
|
||||
assert result is False, "Should return False after exhausting retries"
|
||||
# Should attempt MAX_LOCK_RETRIES + 1 times (initial + retries)
|
||||
assert call_count == MAX_LOCK_RETRIES + 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_lock_error_fails_immediately(self):
|
||||
"""Non-lock errors cause immediate failure without retry."""
|
||||
from integrations.graphiti.queries_pkg.client import GraphitiClient
|
||||
|
||||
config = self._make_config()
|
||||
client = GraphitiClient(config)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def connection_error(db=""):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise RuntimeError("Connection refused - server not running")
|
||||
|
||||
mock_kuzu_driver = MagicMock()
|
||||
mock_kuzu_driver.create_patched_kuzu_driver = connection_error
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_providers": self._make_mock_providers(),
|
||||
"integrations.graphiti.queries_pkg.kuzu_driver_patched": mock_kuzu_driver,
|
||||
}),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client._apply_ladybug_monkeypatch",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client.capture_exception",
|
||||
),
|
||||
):
|
||||
result = await client.initialize()
|
||||
|
||||
assert call_count == 1, "Non-lock errors should not trigger retries"
|
||||
assert result is False
|
||||
|
||||
@@ -1630,3 +1630,144 @@ class TestEdgeCaseStateTransitions:
|
||||
# All subtasks blocked = effectively pending state = backlog
|
||||
assert plan.status == "backlog"
|
||||
assert plan.planStatus == "pending"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STUCK SUBTASK SKIPPING TESTS (progress.py get_next_subtask)
|
||||
# =============================================================================
|
||||
|
||||
class TestStuckSubtaskSkipping:
|
||||
"""Tests for stuck subtask skipping in progress.get_next_subtask()."""
|
||||
|
||||
def _make_plan(self, subtasks):
|
||||
"""Helper to create a minimal implementation_plan.json dict."""
|
||||
return {
|
||||
"feature": "Test",
|
||||
"workflow_type": "feature",
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Phase 1",
|
||||
"depends_on": [],
|
||||
"subtasks": subtasks,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def _make_attempt_history(self, stuck_ids):
|
||||
"""Helper to create attempt_history.json with stuck subtasks."""
|
||||
return {
|
||||
"subtasks": {},
|
||||
"stuck_subtasks": [
|
||||
{"subtask_id": sid, "reason": "stuck", "escalated_at": "2024-01-01T00:00:00"}
|
||||
for sid in stuck_ids
|
||||
],
|
||||
"metadata": {"created_at": "2024-01-01T00:00:00", "last_updated": "2024-01-01T00:00:00"},
|
||||
}
|
||||
|
||||
def test_stuck_subtask_is_skipped(self, temp_dir):
|
||||
"""Stuck subtasks are skipped when selecting the next subtask."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = temp_dir / "spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
# Create plan with two pending subtasks
|
||||
plan = self._make_plan([
|
||||
{"id": "stuck-1", "description": "Stuck task", "status": "pending"},
|
||||
{"id": "good-1", "description": "Normal task", "status": "pending"},
|
||||
])
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
# Mark stuck-1 as stuck
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(parents=True)
|
||||
history = self._make_attempt_history(["stuck-1"])
|
||||
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "good-1", "Should skip stuck-1 and select good-1"
|
||||
|
||||
def test_normal_subtask_selected_when_stuck_exist(self, temp_dir):
|
||||
"""Normal pending subtasks are selected even when stuck ones exist."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = temp_dir / "spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
plan = self._make_plan([
|
||||
{"id": "stuck-a", "description": "Stuck A", "status": "pending"},
|
||||
{"id": "stuck-b", "description": "Stuck B", "status": "pending"},
|
||||
{"id": "normal-c", "description": "Normal C", "status": "pending"},
|
||||
])
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(parents=True)
|
||||
history = self._make_attempt_history(["stuck-a", "stuck-b"])
|
||||
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "normal-c"
|
||||
|
||||
def test_no_attempt_history_file(self, temp_dir):
|
||||
"""When attempt_history.json doesn't exist, normal selection proceeds."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = temp_dir / "spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
plan = self._make_plan([
|
||||
{"id": "task-1", "description": "Task 1", "status": "pending"},
|
||||
])
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
# No memory directory or attempt_history.json
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "task-1"
|
||||
|
||||
def test_corrupted_attempt_history_json(self, temp_dir):
|
||||
"""When attempt_history.json is corrupted, normal selection proceeds."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = temp_dir / "spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
plan = self._make_plan([
|
||||
{"id": "task-1", "description": "Task 1", "status": "pending"},
|
||||
])
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(parents=True)
|
||||
(memory_dir / "attempt_history.json").write_text("{invalid json!!!")
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "task-1", "Should still select task when JSON is corrupted"
|
||||
|
||||
def test_all_pending_subtasks_stuck_returns_none(self, temp_dir):
|
||||
"""When ALL pending subtasks are stuck, returns None."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = temp_dir / "spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
plan = self._make_plan([
|
||||
{"id": "stuck-1", "description": "Stuck 1", "status": "pending"},
|
||||
{"id": "stuck-2", "description": "Stuck 2", "status": "pending"},
|
||||
{"id": "done-1", "description": "Done 1", "status": "completed"},
|
||||
])
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(parents=True)
|
||||
history = self._make_attempt_history(["stuck-1", "stuck-2"])
|
||||
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is None, "Should return None when all pending subtasks are stuck"
|
||||
|
||||
@@ -4,14 +4,22 @@ Tests for prompt_generator module functions.
|
||||
Tests for worktree detection and environment context generation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Note: sys.path manipulation is handled by conftest.py line 46
|
||||
from prompts_pkg.prompt_generator import (
|
||||
detect_worktree_isolation,
|
||||
generate_environment_context,
|
||||
)
|
||||
|
||||
# Skip Windows-specific tests on non-Windows platforms
|
||||
is_windows = sys.platform == 'win32'
|
||||
skip_on_windows = pytest.mark.skipif(not is_windows, reason="Test only applies to Windows")
|
||||
skip_on_non_windows = pytest.mark.skipif(is_windows, reason="Test only applies to non-Windows platforms")
|
||||
|
||||
|
||||
def normalize_path(path_str: str) -> str:
|
||||
"""Normalize path string for cross-platform comparison."""
|
||||
@@ -36,6 +44,7 @@ class TestDetectWorktreeIsolation:
|
||||
assert "opt/dev/project" in norm_forbidden
|
||||
assert ".auto-claude" not in norm_forbidden
|
||||
|
||||
@skip_on_windows
|
||||
def test_new_worktree_windows_path(self):
|
||||
"""Test detection of new worktree location on Windows."""
|
||||
# Windows path with backslashes
|
||||
@@ -64,6 +73,7 @@ class TestDetectWorktreeIsolation:
|
||||
assert "opt/dev/project" in norm_forbidden
|
||||
assert ".worktrees" not in norm_forbidden
|
||||
|
||||
@skip_on_windows
|
||||
def test_legacy_worktree_windows_path(self):
|
||||
"""Test detection of legacy worktree location on Windows."""
|
||||
from unittest.mock import patch
|
||||
|
||||
+35
-32
@@ -524,57 +524,56 @@ class TestShouldRunQA:
|
||||
|
||||
def test_should_run_qa_build_not_complete(self, spec_dir: Path):
|
||||
"""Returns False when build not complete."""
|
||||
# Set up mock to return build not complete
|
||||
mock_progress.is_build_complete.return_value = False
|
||||
from unittest.mock import patch
|
||||
|
||||
plan = {"feature": "Test", "phases": []}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is False
|
||||
|
||||
# Reset mock
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
with patch('qa.criteria.is_build_complete', return_value=False):
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is False
|
||||
|
||||
def test_should_run_qa_already_approved(self, spec_dir: Path, qa_signoff_approved: dict):
|
||||
"""Returns False when already approved."""
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
from unittest.mock import patch
|
||||
|
||||
plan = {"feature": "Test", "qa_signoff": qa_signoff_approved}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is False
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is False
|
||||
|
||||
def test_should_run_qa_build_complete_not_approved(self, spec_dir: Path):
|
||||
"""Returns True when build complete but not approved."""
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
# Explicitly patch is_build_complete to return True
|
||||
from unittest.mock import patch
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
plan = {"feature": "Test", "phases": []}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
plan = {"feature": "Test", "phases": []}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is True
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is True
|
||||
|
||||
def test_should_run_qa_rejected_status(self, spec_dir: Path, qa_signoff_rejected: dict):
|
||||
"""Returns True when rejected (needs re-review after fixes)."""
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
from unittest.mock import patch
|
||||
|
||||
qa_signoff_rejected["qa_session"] = 1
|
||||
plan = {"feature": "Test", "qa_signoff": qa_signoff_rejected}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is True
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is True
|
||||
|
||||
def test_should_run_qa_no_plan(self, spec_dir: Path):
|
||||
"""Returns False when no plan exists (build not complete)."""
|
||||
mock_progress.is_build_complete.return_value = False
|
||||
from unittest.mock import patch
|
||||
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is False
|
||||
|
||||
# Reset mock
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
with patch('qa.criteria.is_build_complete', return_value=False):
|
||||
result = should_run_qa(spec_dir)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestShouldRunFixes:
|
||||
@@ -899,14 +898,15 @@ class TestQAIntegration:
|
||||
|
||||
def test_full_qa_workflow_approved_first_try(self, spec_dir: Path):
|
||||
"""Full workflow where QA approves on first try."""
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
from unittest.mock import patch
|
||||
|
||||
# Build complete
|
||||
plan = {"feature": "Test Feature", "phases": []}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Should run QA
|
||||
assert should_run_qa(spec_dir) is True
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
assert should_run_qa(spec_dir) is True
|
||||
|
||||
# QA approves
|
||||
plan["qa_signoff"] = {
|
||||
@@ -917,20 +917,22 @@ class TestQAIntegration:
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Should not run QA again or fixes
|
||||
assert should_run_qa(spec_dir) is False
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
assert should_run_qa(spec_dir) is False
|
||||
assert should_run_fixes(spec_dir) is False
|
||||
assert is_qa_approved(spec_dir) is True
|
||||
|
||||
def test_full_qa_workflow_with_fixes(self, spec_dir: Path):
|
||||
"""Full workflow with reject-fix-approve cycle."""
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
from unittest.mock import patch
|
||||
|
||||
# Build complete
|
||||
plan = {"feature": "Test Feature", "phases": []}
|
||||
save_implementation_plan(spec_dir, plan)
|
||||
|
||||
# Should run QA
|
||||
assert should_run_qa(spec_dir) is True
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
assert should_run_qa(spec_dir) is True
|
||||
|
||||
# QA rejects
|
||||
plan["qa_signoff"] = {
|
||||
@@ -963,7 +965,7 @@ class TestQAIntegration:
|
||||
|
||||
def test_qa_workflow_max_iterations(self, spec_dir: Path):
|
||||
"""Test behavior when max iterations are reached."""
|
||||
mock_progress.is_build_complete.return_value = True
|
||||
from unittest.mock import patch
|
||||
|
||||
plan = {
|
||||
"feature": "Test",
|
||||
@@ -977,4 +979,5 @@ class TestQAIntegration:
|
||||
# Should not run more fixes after max iterations
|
||||
assert should_run_fixes(spec_dir) is False
|
||||
# But QA can still be run (to re-check)
|
||||
assert should_run_qa(spec_dir) is True
|
||||
with patch('qa.criteria.is_build_complete', return_value=True):
|
||||
assert should_run_qa(spec_dir) is True
|
||||
|
||||
+317
-19
@@ -13,6 +13,7 @@ Tests the recovery system functionality including:
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -533,6 +534,322 @@ def test_checkpoint_clear_and_reset(test_env):
|
||||
assert manager2.get_attempt_count("subtask-1") == 2, "subtask-1 history lost"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TIME-WINDOW FILTERING TESTS (get_attempt_count)
|
||||
# =============================================================================
|
||||
|
||||
def test_get_attempt_count_time_window_filtering(test_env):
|
||||
"""Test that get_attempt_count only counts attempts within the 2-hour window."""
|
||||
from datetime import timedelta
|
||||
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
old_time = (datetime.now() - timedelta(hours=3)).isoformat()
|
||||
recent_time = (datetime.now() - timedelta(minutes=30)).isoformat()
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-1"] = {
|
||||
"attempts": [
|
||||
{"timestamp": old_time, "approach": "old approach", "success": False},
|
||||
{"timestamp": recent_time, "approach": "recent approach", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
count = manager.get_attempt_count("test-1")
|
||||
assert count == 1, "Should only count the recent attempt within 2-hour window"
|
||||
|
||||
|
||||
def test_get_attempt_count_boundary_just_inside_and_outside(test_env):
|
||||
"""Test attempts just inside and outside the 2-hour cutoff boundary."""
|
||||
from datetime import timedelta
|
||||
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
# 1 second inside the window (1h 59m 59s ago) - should be included
|
||||
inside_time = (datetime.now() - timedelta(seconds=7199)).isoformat()
|
||||
# 10 seconds outside the window (2h 10s ago) - should be excluded
|
||||
outside_time = (datetime.now() - timedelta(seconds=7210)).isoformat()
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-boundary"] = {
|
||||
"attempts": [
|
||||
{"timestamp": inside_time, "approach": "inside window", "success": False},
|
||||
{"timestamp": outside_time, "approach": "outside window", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
count = manager.get_attempt_count("test-boundary")
|
||||
assert count == 1, "Attempt inside window should be counted, outside should not"
|
||||
|
||||
|
||||
def test_get_attempt_count_all_outside_window(test_env):
|
||||
"""Test that all attempts outside the time window returns 0."""
|
||||
from datetime import timedelta
|
||||
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
old_time_1 = (datetime.now() - timedelta(hours=5)).isoformat()
|
||||
old_time_2 = (datetime.now() - timedelta(hours=4)).isoformat()
|
||||
old_time_3 = (datetime.now() - timedelta(hours=3)).isoformat()
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-old"] = {
|
||||
"attempts": [
|
||||
{"timestamp": old_time_1, "approach": "old 1", "success": False},
|
||||
{"timestamp": old_time_2, "approach": "old 2", "success": False},
|
||||
{"timestamp": old_time_3, "approach": "old 3", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
count = manager.get_attempt_count("test-old")
|
||||
assert count == 0, "All attempts outside window should result in count of 0"
|
||||
|
||||
|
||||
def test_get_attempt_count_all_recent(test_env):
|
||||
"""Test that all recent attempts are counted."""
|
||||
from datetime import timedelta
|
||||
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
times = [
|
||||
(datetime.now() - timedelta(minutes=10)).isoformat(),
|
||||
(datetime.now() - timedelta(minutes=30)).isoformat(),
|
||||
(datetime.now() - timedelta(minutes=90)).isoformat(),
|
||||
]
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-recent"] = {
|
||||
"attempts": [
|
||||
{"timestamp": times[0], "approach": "a1", "success": False},
|
||||
{"timestamp": times[1], "approach": "a2", "success": False},
|
||||
{"timestamp": times[2], "approach": "a3", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
count = manager.get_attempt_count("test-recent")
|
||||
assert count == 3, "All recent attempts should be counted"
|
||||
|
||||
|
||||
def test_get_attempt_count_missing_timestamp_backward_compat(test_env):
|
||||
"""Test backward compatibility: attempts without timestamps are counted as recent."""
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-no-ts"] = {
|
||||
"attempts": [
|
||||
{"approach": "no timestamp", "success": False},
|
||||
{"approach": "also no timestamp", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
count = manager.get_attempt_count("test-no-ts")
|
||||
assert count == 2, "Attempts without timestamps should be counted (backward compat)"
|
||||
|
||||
|
||||
def test_get_attempt_count_invalid_timestamp_backward_compat(test_env):
|
||||
"""Test backward compatibility: attempts with invalid timestamps are counted as recent."""
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-bad-ts"] = {
|
||||
"attempts": [
|
||||
{"timestamp": "not-a-date", "approach": "bad ts", "success": False},
|
||||
{"timestamp": "2024-13-99T99:99:99", "approach": "invalid ts", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
count = manager.get_attempt_count("test-bad-ts")
|
||||
assert count == 2, "Attempts with invalid timestamps should be counted (backward compat)"
|
||||
|
||||
|
||||
def test_get_attempt_count_mixed_timestamps(test_env):
|
||||
"""Test mixed scenario: some attempts with timestamps, some without."""
|
||||
from datetime import timedelta
|
||||
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
old_time = (datetime.now() - timedelta(hours=5)).isoformat()
|
||||
recent_time = (datetime.now() - timedelta(minutes=10)).isoformat()
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["test-mixed"] = {
|
||||
"attempts": [
|
||||
{"timestamp": old_time, "approach": "old", "success": False},
|
||||
{"timestamp": recent_time, "approach": "recent", "success": False},
|
||||
{"approach": "no timestamp", "success": False},
|
||||
{"timestamp": "garbage", "approach": "bad timestamp", "success": False},
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
# old_time: excluded (outside window)
|
||||
# recent_time: included (within window)
|
||||
# no timestamp: included (backward compat)
|
||||
# bad timestamp: included (backward compat)
|
||||
count = manager.get_attempt_count("test-mixed")
|
||||
assert count == 3, "Should count recent + missing/invalid timestamps, exclude old"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ATTEMPT HISTORY TRIMMING TESTS (record_attempt)
|
||||
# =============================================================================
|
||||
|
||||
def test_record_attempt_trimming_at_51(test_env):
|
||||
"""Test that recording the 51st attempt triggers trimming to 50."""
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
# Manually inject 50 attempts
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["trim-test"] = {
|
||||
"attempts": [
|
||||
{
|
||||
"session": i,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"approach": f"approach-{i}",
|
||||
"success": False,
|
||||
"error": None,
|
||||
}
|
||||
for i in range(50)
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
# Record the 51st attempt
|
||||
manager.record_attempt("trim-test", 51, False, "approach-50", "error")
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
attempts = history["subtasks"]["trim-test"]["attempts"]
|
||||
assert len(attempts) == 50, "Should trim to 50 after exceeding cap"
|
||||
|
||||
|
||||
def test_record_attempt_trimming_keeps_newest(test_env):
|
||||
"""Test that trimming keeps the newest 50 attempts, not the oldest."""
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
# Inject 50 attempts with identifiable approaches
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["trim-order"] = {
|
||||
"attempts": [
|
||||
{
|
||||
"session": i,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"approach": f"old-approach-{i}",
|
||||
"success": False,
|
||||
"error": None,
|
||||
}
|
||||
for i in range(50)
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
# Record new attempt (triggers trim)
|
||||
manager.record_attempt("trim-order", 99, False, "newest-approach", "error")
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
attempts = history["subtasks"]["trim-order"]["attempts"]
|
||||
assert len(attempts) == 50
|
||||
|
||||
# The oldest attempt (old-approach-0) should be gone
|
||||
approaches = [a["approach"] for a in attempts]
|
||||
assert "old-approach-0" not in approaches, "Oldest attempt should be trimmed"
|
||||
# The newest attempt should be present
|
||||
assert "newest-approach" in approaches, "Newest attempt should be kept"
|
||||
# old-approach-1 should be the oldest remaining
|
||||
assert "old-approach-1" in approaches, "Second oldest should now be first"
|
||||
|
||||
|
||||
def test_record_attempt_no_trimming_at_exactly_50(test_env):
|
||||
"""Test that exactly 50 attempts does not trigger trimming."""
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
# Inject 49 attempts
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["no-trim"] = {
|
||||
"attempts": [
|
||||
{
|
||||
"session": i,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"approach": f"approach-{i}",
|
||||
"success": False,
|
||||
"error": None,
|
||||
}
|
||||
for i in range(49)
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
# Record the 50th attempt (should NOT trigger trimming)
|
||||
manager.record_attempt("no-trim", 50, False, "approach-49", "error")
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
attempts = history["subtasks"]["no-trim"]["attempts"]
|
||||
assert len(attempts) == 50, "Exactly 50 should not trigger trimming"
|
||||
# First attempt should still be present
|
||||
assert attempts[0]["approach"] == "approach-0", "No attempts should be removed"
|
||||
|
||||
|
||||
def test_record_attempt_trimming_from_100(test_env):
|
||||
"""Test trimming from 100 attempts keeps exactly 50."""
|
||||
temp_dir, spec_dir, project_dir = test_env
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
# Inject 100 attempts
|
||||
history = manager._load_attempt_history()
|
||||
history["subtasks"]["big-trim"] = {
|
||||
"attempts": [
|
||||
{
|
||||
"session": i,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"approach": f"approach-{i}",
|
||||
"success": False,
|
||||
"error": None,
|
||||
}
|
||||
for i in range(100)
|
||||
],
|
||||
"status": "failed",
|
||||
}
|
||||
manager._save_attempt_history(history)
|
||||
|
||||
# Record attempt 101 (triggers trim from 101 -> 50)
|
||||
manager.record_attempt("big-trim", 101, False, "approach-100", "error")
|
||||
|
||||
history = manager._load_attempt_history()
|
||||
attempts = history["subtasks"]["big-trim"]["attempts"]
|
||||
assert len(attempts) == 50, "Should trim to exactly 50"
|
||||
|
||||
# Verify newest are kept
|
||||
approaches = [a["approach"] for a in attempts]
|
||||
assert "approach-100" in approaches, "Newest attempt should be kept"
|
||||
assert "approach-0" not in approaches, "Oldest attempts should be trimmed"
|
||||
assert "approach-50" not in approaches, "Mid-range old attempts should be trimmed"
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all tests."""
|
||||
print("=" * 70)
|
||||
@@ -543,25 +860,6 @@ def run_all_tests():
|
||||
# Note: This manual runner is kept for backwards compatibility.
|
||||
# Prefer running tests with pytest: pytest tests/test_recovery.py -v
|
||||
|
||||
tests = [
|
||||
("test_initialization", test_initialization),
|
||||
("test_record_attempt", test_record_attempt),
|
||||
("test_circular_fix_detection", test_circular_fix_detection),
|
||||
("test_failure_classification", test_failure_classification),
|
||||
("test_recovery_action_determination", test_recovery_action_determination),
|
||||
("test_good_commit_tracking", test_good_commit_tracking),
|
||||
("test_mark_subtask_stuck", test_mark_subtask_stuck),
|
||||
("test_recovery_hints", test_recovery_hints),
|
||||
# Session checkpoint and restoration tests
|
||||
("test_checkpoint_persistence_across_sessions", test_checkpoint_persistence_across_sessions),
|
||||
("test_restoration_after_failure", test_restoration_after_failure),
|
||||
("test_checkpoint_multiple_subtasks", test_checkpoint_multiple_subtasks),
|
||||
("test_restoration_with_build_commits", test_restoration_with_build_commits),
|
||||
("test_checkpoint_recovery_hints_restoration", test_checkpoint_recovery_hints_restoration),
|
||||
("test_restoration_stuck_subtasks_list", test_restoration_stuck_subtasks_list),
|
||||
("test_checkpoint_clear_and_reset", test_checkpoint_clear_and_reset),
|
||||
]
|
||||
|
||||
print("Note: Running with manual test runner for backwards compatibility.")
|
||||
print("For full pytest integration with fixtures, run: pytest tests/test_recovery.py -v")
|
||||
print()
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""
|
||||
Test Claude Agent SDK Structured Output functionality.
|
||||
|
||||
This test verifies how structured outputs work with the SDK.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
|
||||
|
||||
# Add pydantic_models path
|
||||
_pydantic_models_path = (
|
||||
Path(__file__).parent.parent
|
||||
/ "apps"
|
||||
/ "backend"
|
||||
/ "runners"
|
||||
/ "github"
|
||||
/ "services"
|
||||
)
|
||||
sys.path.insert(0, str(_pydantic_models_path))
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
# Simple test model
|
||||
class SimpleReviewResponse(BaseModel):
|
||||
"""A simple review response for testing."""
|
||||
|
||||
verdict: Literal["PASS", "FAIL"] = Field(description="Review verdict")
|
||||
reason: str = Field(description="Reason for the verdict")
|
||||
score: int = Field(ge=0, le=100, description="Score from 0-100")
|
||||
|
||||
|
||||
async def test_structured_output():
|
||||
"""Test the SDK's structured output functionality."""
|
||||
|
||||
# OAuth token must be set in environment (CLAUDE_CODE_OAUTH_TOKEN)
|
||||
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
print("ERROR: CLAUDE_CODE_OAUTH_TOKEN environment variable not set")
|
||||
return
|
||||
|
||||
from claude_agent_sdk import query, ClaudeAgentOptions
|
||||
|
||||
# Generate JSON schema from Pydantic model
|
||||
schema = SimpleReviewResponse.model_json_schema()
|
||||
print("=== Schema ===")
|
||||
pprint(schema)
|
||||
print()
|
||||
|
||||
prompt = """
|
||||
Review this code and provide your assessment:
|
||||
|
||||
```python
|
||||
def add(a, b):
|
||||
return a + b
|
||||
```
|
||||
|
||||
Provide a verdict (PASS or FAIL), reason, and score.
|
||||
"""
|
||||
|
||||
print("=== Running query with output_format ===")
|
||||
print(f"Prompt: {prompt[:100]}...")
|
||||
print()
|
||||
|
||||
message_count = 0
|
||||
async for message in query(
|
||||
prompt=prompt,
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
system_prompt="You are a code reviewer. Provide structured feedback.",
|
||||
allowed_tools=[],
|
||||
max_turns=2, # Need 2 turns for structured output tool call
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": schema,
|
||||
},
|
||||
),
|
||||
):
|
||||
message_count += 1
|
||||
msg_type = type(message).__name__
|
||||
print(f"\n=== Message {message_count}: {msg_type} ===")
|
||||
|
||||
# Print all non-private attributes
|
||||
for attr in dir(message):
|
||||
if not attr.startswith("_"):
|
||||
try:
|
||||
val = getattr(message, attr)
|
||||
if not callable(val):
|
||||
# Truncate long values
|
||||
val_str = str(val)
|
||||
if len(val_str) > 500:
|
||||
val_str = val_str[:500] + "..."
|
||||
print(f" {attr}: {val_str}")
|
||||
except Exception as e:
|
||||
print(f" {attr}: <error: {e}>")
|
||||
|
||||
# Check for StructuredOutput tool use in AssistantMessage
|
||||
if msg_type == "AssistantMessage":
|
||||
content = getattr(message, "content", [])
|
||||
for block in content:
|
||||
block_type = type(block).__name__
|
||||
if block_type == "ToolUseBlock":
|
||||
tool_name = getattr(block, "name", "")
|
||||
if tool_name == "StructuredOutput":
|
||||
structured_data = getattr(block, "input", None)
|
||||
print(f"\n 🎯 Found StructuredOutput tool use!")
|
||||
print(f" Data: {structured_data}")
|
||||
if structured_data:
|
||||
try:
|
||||
validated = SimpleReviewResponse.model_validate(structured_data)
|
||||
print(f"\n ✅ Successfully validated StructuredOutput!")
|
||||
print(f" verdict: {validated.verdict}")
|
||||
print(f" reason: {validated.reason}")
|
||||
print(f" score: {validated.score}")
|
||||
except Exception as e:
|
||||
print(f"\n ❌ Failed to validate: {e}")
|
||||
|
||||
# Special handling for ResultMessage
|
||||
if msg_type == "ResultMessage":
|
||||
print("\n --- ResultMessage Details ---")
|
||||
|
||||
# Check structured_output
|
||||
so = getattr(message, "structured_output", None)
|
||||
print(f" structured_output value: {so}")
|
||||
print(f" structured_output type: {type(so)}")
|
||||
|
||||
# Check result
|
||||
result = getattr(message, "result", None)
|
||||
print(f" result value: {result}")
|
||||
print(f" result type: {type(result)}")
|
||||
|
||||
# If result is a string, try to parse as JSON
|
||||
if isinstance(result, str):
|
||||
import json
|
||||
try:
|
||||
parsed = json.loads(result)
|
||||
print(f" result parsed as JSON: {parsed}")
|
||||
except:
|
||||
print(f" result is not JSON")
|
||||
|
||||
# Try to validate with Pydantic if we got data
|
||||
if so:
|
||||
try:
|
||||
validated = SimpleReviewResponse.model_validate(so)
|
||||
print(f"\n ✅ Successfully validated structured_output!")
|
||||
print(f" verdict: {validated.verdict}")
|
||||
print(f" reason: {validated.reason}")
|
||||
print(f" score: {validated.score}")
|
||||
except Exception as e:
|
||||
print(f"\n ❌ Failed to validate structured_output: {e}")
|
||||
|
||||
if result and isinstance(result, (dict, str)):
|
||||
try:
|
||||
data = result if isinstance(result, dict) else json.loads(result)
|
||||
validated = SimpleReviewResponse.model_validate(data)
|
||||
print(f"\n ✅ Successfully validated result as structured output!")
|
||||
print(f" verdict: {validated.verdict}")
|
||||
print(f" reason: {validated.reason}")
|
||||
print(f" score: {validated.score}")
|
||||
except Exception as e:
|
||||
print(f"\n ❌ Failed to validate result: {e}")
|
||||
|
||||
print(f"\n=== Total messages: {message_count} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_structured_output())
|
||||
@@ -382,7 +382,7 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, scanner):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
fake_dir = Path("/tmp/test-nonexistent-security-scanner-123456")
|
||||
|
||||
# Should not crash, may have errors - mock exists to avoid permission error
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
|
||||
@@ -438,7 +438,7 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
fake_dir = Path("/tmp/test-nonexistent-orchestrator-123456")
|
||||
|
||||
# Should not crash - mock exists to avoid permission error
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
|
||||
@@ -18,17 +18,22 @@ import pytest
|
||||
# services/ package at both apps/backend/services/ and runners/github/services/.
|
||||
# To avoid collision, add the github services dir directly and import bare module names.
|
||||
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
|
||||
_github_services_dir = _backend_dir / "runners" / "github" / "services"
|
||||
_github_runner_dir = _backend_dir / "runners" / "github"
|
||||
_github_services_dir = _github_runner_dir / "services"
|
||||
if str(_backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_backend_dir))
|
||||
if str(_github_runner_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_runner_dir))
|
||||
if str(_github_services_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_services_dir))
|
||||
|
||||
from agents.tools_pkg.models import AGENT_CONFIGS
|
||||
from pydantic_models import (
|
||||
ExtractedFindingSummary,
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from recovery_utils import create_finding_from_summary
|
||||
from sdk_utils import RECOVERABLE_ERRORS
|
||||
|
||||
|
||||
@@ -53,20 +58,38 @@ class TestFollowupExtractionResponse:
|
||||
assert resp.dismissed_finding_count == 0
|
||||
|
||||
def test_full_valid_response(self):
|
||||
"""Accepts fully populated response."""
|
||||
"""Accepts fully populated response with ExtractedFindingSummary objects."""
|
||||
resp = FollowupExtractionResponse(
|
||||
verdict="READY_TO_MERGE",
|
||||
verdict_reasoning="All findings resolved",
|
||||
resolved_finding_ids=["NCR-001", "NCR-002"],
|
||||
unresolved_finding_ids=[],
|
||||
new_finding_summaries=["HIGH: potential cleanup issue in batch_commands.py"],
|
||||
new_finding_summaries=[
|
||||
ExtractedFindingSummary(
|
||||
severity="HIGH",
|
||||
description="potential cleanup issue in batch_commands.py",
|
||||
file="apps/backend/cli/batch_commands.py",
|
||||
line=42,
|
||||
)
|
||||
],
|
||||
confirmed_finding_count=1,
|
||||
dismissed_finding_count=1,
|
||||
)
|
||||
assert len(resp.resolved_finding_ids) == 2
|
||||
assert len(resp.new_finding_summaries) == 1
|
||||
assert resp.new_finding_summaries[0].file == "apps/backend/cli/batch_commands.py"
|
||||
assert resp.new_finding_summaries[0].line == 42
|
||||
assert resp.confirmed_finding_count == 1
|
||||
|
||||
def test_finding_summary_defaults(self):
|
||||
"""ExtractedFindingSummary defaults file='unknown' and line=0."""
|
||||
summary = ExtractedFindingSummary(
|
||||
severity="MEDIUM",
|
||||
description="Some issue without location",
|
||||
)
|
||||
assert summary.file == "unknown"
|
||||
assert summary.line == 0
|
||||
|
||||
def test_schema_is_small(self):
|
||||
"""Schema should be significantly smaller than ParallelFollowupResponse."""
|
||||
extraction_schema = json.dumps(
|
||||
@@ -75,10 +98,11 @@ class TestFollowupExtractionResponse:
|
||||
followup_schema = json.dumps(
|
||||
ParallelFollowupResponse.model_json_schema()
|
||||
)
|
||||
# Extraction schema should be less than half the size of the full schema
|
||||
assert len(extraction_schema) < len(followup_schema) / 2, (
|
||||
# Actual ratio is ~50.7% after adding ExtractedFindingSummary nesting.
|
||||
# Threshold at 55% gives headroom while still guarding against schema bloat.
|
||||
assert len(extraction_schema) < len(followup_schema) * 0.55, (
|
||||
f"Extraction schema ({len(extraction_schema)} chars) should be "
|
||||
f"less than half of full schema ({len(followup_schema)} chars)"
|
||||
f"less than 55% of full schema ({len(followup_schema)} chars)"
|
||||
)
|
||||
|
||||
def test_all_verdict_values_accepted(self):
|
||||
@@ -143,3 +167,81 @@ class TestAgentConfigRegistration:
|
||||
"""Extraction agent should use low thinking (lightweight call)."""
|
||||
config = AGENT_CONFIGS["pr_followup_extraction"]
|
||||
assert config["thinking_default"] == "low"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test create_finding_from_summary with file/line params
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestCreateFindingFromSummary:
|
||||
"""Tests for create_finding_from_summary with file/line support."""
|
||||
|
||||
def test_backward_compatible_defaults(self):
|
||||
"""Calling without file/line still produces file='unknown', line=0."""
|
||||
finding = create_finding_from_summary("HIGH: some issue", 0)
|
||||
assert finding.file == "unknown"
|
||||
assert finding.line == 0
|
||||
assert finding.severity.value == "high"
|
||||
|
||||
def test_file_and_line_passed_through(self):
|
||||
"""File and line params are used in the resulting finding."""
|
||||
finding = create_finding_from_summary(
|
||||
summary="Missing null check",
|
||||
index=0,
|
||||
file="src/parser.py",
|
||||
line=42,
|
||||
)
|
||||
assert finding.file == "src/parser.py"
|
||||
assert finding.line == 42
|
||||
|
||||
def test_severity_override(self):
|
||||
"""severity_override takes precedence over parsed severity."""
|
||||
finding = create_finding_from_summary(
|
||||
summary="HIGH: some issue",
|
||||
index=0,
|
||||
severity_override="CRITICAL",
|
||||
)
|
||||
assert finding.severity.value == "critical"
|
||||
|
||||
def test_severity_override_case_insensitive(self):
|
||||
"""severity_override works regardless of case."""
|
||||
finding = create_finding_from_summary(
|
||||
summary="some issue",
|
||||
index=0,
|
||||
severity_override="high",
|
||||
)
|
||||
assert finding.severity.value == "high"
|
||||
|
||||
def test_severity_override_invalid_falls_back(self):
|
||||
"""Invalid severity_override falls back to parsed severity."""
|
||||
finding = create_finding_from_summary(
|
||||
summary="LOW: minor issue",
|
||||
index=0,
|
||||
severity_override="UNKNOWN",
|
||||
)
|
||||
# Falls back to parsed "LOW" from summary
|
||||
assert finding.severity.value == "low"
|
||||
|
||||
def test_id_prefix(self):
|
||||
"""Custom id_prefix is used in the finding ID."""
|
||||
finding = create_finding_from_summary(
|
||||
summary="some issue", index=0, id_prefix="FU"
|
||||
)
|
||||
assert finding.id.startswith("FU-")
|
||||
|
||||
def test_all_params_together(self):
|
||||
"""All new params work together correctly."""
|
||||
finding = create_finding_from_summary(
|
||||
summary="Regex issue in subtask title truncation",
|
||||
index=3,
|
||||
id_prefix="FU",
|
||||
severity_override="MEDIUM",
|
||||
file="apps/backend/agents/planner.py",
|
||||
line=187,
|
||||
)
|
||||
assert finding.id.startswith("FU-")
|
||||
assert finding.severity.value == "medium"
|
||||
assert finding.file == "apps/backend/agents/planner.py"
|
||||
assert finding.line == 187
|
||||
assert "Regex issue" in finding.title
|
||||
|
||||
+159
-286
@@ -29,20 +29,13 @@ from pydantic_models import (
|
||||
FindingResolution,
|
||||
FollowupFinding,
|
||||
FollowupReviewResponse,
|
||||
# Orchestrator review models
|
||||
OrchestratorFinding,
|
||||
OrchestratorReviewResponse,
|
||||
# Initial review models
|
||||
QuickScanResult,
|
||||
SecurityFinding,
|
||||
QualityFinding,
|
||||
DeepAnalysisFinding,
|
||||
StructuralIssue,
|
||||
AICommentTriage,
|
||||
# Verification evidence models (Phase 2)
|
||||
# Verification evidence models
|
||||
VerificationEvidence,
|
||||
ParallelOrchestratorFinding,
|
||||
BaseFinding,
|
||||
# Specialist models
|
||||
SpecialistFinding,
|
||||
# Parallel follow-up models
|
||||
ParallelFollowupFinding,
|
||||
)
|
||||
|
||||
|
||||
@@ -86,7 +79,7 @@ class TestFollowupFinding:
|
||||
"""Tests for FollowupFinding model."""
|
||||
|
||||
def test_valid_finding(self):
|
||||
"""Test valid follow-up finding."""
|
||||
"""Test valid follow-up finding (no verification required)."""
|
||||
data = {
|
||||
"id": "new-1",
|
||||
"severity": "high",
|
||||
@@ -97,11 +90,6 @@ class TestFollowupFinding:
|
||||
"line": 42,
|
||||
"suggested_fix": "Use parameterized queries",
|
||||
"fixable": True,
|
||||
"verification": {
|
||||
"code_examined": "query = 'SELECT * FROM users WHERE id=' + user_input",
|
||||
"line_range_examined": [42, 42],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = FollowupFinding.model_validate(data)
|
||||
assert result.id == "new-1"
|
||||
@@ -119,44 +107,52 @@ class TestFollowupFinding:
|
||||
"title": "Missing docstring",
|
||||
"description": "Function lacks documentation",
|
||||
"file": "utils.py",
|
||||
"verification": {
|
||||
"code_examined": "def process_data(data):\n return data",
|
||||
"line_range_examined": [1, 2],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = FollowupFinding.model_validate(data)
|
||||
assert result.line == 0 # Default
|
||||
assert result.suggested_fix is None
|
||||
assert result.fixable is False
|
||||
|
||||
def test_invalid_severity_rejected(self):
|
||||
"""Test that invalid severity is rejected."""
|
||||
def test_invalid_severity_normalized(self):
|
||||
"""Test that invalid severity is normalized to 'medium'."""
|
||||
data = {
|
||||
"id": "new-1",
|
||||
"severity": "extreme", # Invalid
|
||||
"severity": "extreme", # Invalid — normalized to medium
|
||||
"category": "security",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"file": "test.py",
|
||||
}
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
FollowupFinding.model_validate(data)
|
||||
assert "severity" in str(exc_info.value)
|
||||
result = FollowupFinding.model_validate(data)
|
||||
assert result.severity == "medium"
|
||||
|
||||
def test_invalid_category_rejected(self):
|
||||
"""Test that invalid category is rejected."""
|
||||
def test_invalid_category_normalized(self):
|
||||
"""Test that invalid category is normalized to 'quality'."""
|
||||
data = {
|
||||
"id": "new-1",
|
||||
"severity": "high",
|
||||
"category": "unknown_category", # Invalid
|
||||
"category": "unknown_category", # Invalid — normalized to quality
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"file": "test.py",
|
||||
}
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
FollowupFinding.model_validate(data)
|
||||
assert "category" in str(exc_info.value)
|
||||
result = FollowupFinding.model_validate(data)
|
||||
assert result.category == "quality"
|
||||
|
||||
def test_verification_not_required(self):
|
||||
"""Test that verification field is not required on FollowupFinding."""
|
||||
data = {
|
||||
"id": "new-1",
|
||||
"severity": "medium",
|
||||
"category": "quality",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"file": "test.py",
|
||||
}
|
||||
result = FollowupFinding.model_validate(data)
|
||||
assert not hasattr(result, "verification") or not hasattr(
|
||||
result.__class__.model_fields, "verification"
|
||||
)
|
||||
|
||||
|
||||
class TestFollowupReviewResponse:
|
||||
@@ -177,11 +173,6 @@ class TestFollowupReviewResponse:
|
||||
"description": "Complex method",
|
||||
"file": "service.py",
|
||||
"line": 100,
|
||||
"verification": {
|
||||
"code_examined": "def process(self, data):\n # 50 lines of nested if statements",
|
||||
"line_range_examined": [100, 150],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
],
|
||||
"comment_findings": [],
|
||||
@@ -238,125 +229,6 @@ class TestFollowupReviewResponse:
|
||||
assert result.verdict == verdict
|
||||
|
||||
|
||||
class TestOrchestratorFinding:
|
||||
"""Tests for OrchestratorFinding model."""
|
||||
|
||||
def test_valid_finding(self):
|
||||
"""Test valid orchestrator finding with evidence field."""
|
||||
data = {
|
||||
"file": "src/api.py",
|
||||
"line": 25,
|
||||
"title": "Missing error handling",
|
||||
"description": "API endpoint lacks try-catch block",
|
||||
"category": "quality",
|
||||
"severity": "medium",
|
||||
"suggestion": "Add error handling with proper logging",
|
||||
"evidence": "def handle_request(req):\n result = db.query(req.id) # no try-catch",
|
||||
"verification": {
|
||||
"code_examined": "def handle_request(req):\n result = db.query(req.id) # no try-catch",
|
||||
"line_range_examined": [25, 26],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = OrchestratorFinding.model_validate(data)
|
||||
assert result.file == "src/api.py"
|
||||
assert result.evidence is not None
|
||||
assert "no try-catch" in result.evidence
|
||||
|
||||
def test_evidence_optional(self):
|
||||
"""Test that evidence field is optional."""
|
||||
data = {
|
||||
"file": "test.py",
|
||||
"title": "Test",
|
||||
"description": "Test finding",
|
||||
"category": "quality",
|
||||
"severity": "low",
|
||||
"verification": {
|
||||
"code_examined": "def test():\n pass",
|
||||
"line_range_examined": [1, 2],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = OrchestratorFinding.model_validate(data)
|
||||
assert result.evidence is None
|
||||
|
||||
|
||||
class TestOrchestratorReviewResponse:
|
||||
"""Tests for OrchestratorReviewResponse model."""
|
||||
|
||||
def test_valid_response(self):
|
||||
"""Test valid orchestrator review response."""
|
||||
data = {
|
||||
"verdict": "NEEDS_REVISION",
|
||||
"verdict_reasoning": "Critical security issue found",
|
||||
"findings": [
|
||||
{
|
||||
"file": "auth.py",
|
||||
"line": 10,
|
||||
"title": "Hardcoded secret",
|
||||
"description": "API key exposed in source",
|
||||
"category": "security",
|
||||
"severity": "critical",
|
||||
"evidence": "API_KEY = 'sk-prod-12345abcdef'",
|
||||
"verification": {
|
||||
"code_examined": "API_KEY = 'sk-prod-12345abcdef'",
|
||||
"line_range_examined": [10, 10],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
],
|
||||
"summary": "Found 1 critical security issue",
|
||||
}
|
||||
result = OrchestratorReviewResponse.model_validate(data)
|
||||
assert result.verdict == "NEEDS_REVISION"
|
||||
assert len(result.findings) == 1
|
||||
assert result.findings[0].severity == "critical"
|
||||
|
||||
def test_empty_findings(self):
|
||||
"""Test response with no findings."""
|
||||
data = {
|
||||
"verdict": "READY_TO_MERGE",
|
||||
"verdict_reasoning": "All checks passed",
|
||||
"findings": [],
|
||||
"summary": "Clean PR, ready for merge",
|
||||
}
|
||||
result = OrchestratorReviewResponse.model_validate(data)
|
||||
assert len(result.findings) == 0
|
||||
|
||||
|
||||
class TestQuickScanResult:
|
||||
"""Tests for QuickScanResult model."""
|
||||
|
||||
def test_valid_quick_scan(self):
|
||||
"""Test valid quick scan result."""
|
||||
data = {
|
||||
"purpose": "Add user authentication",
|
||||
"actual_changes": "Implements OAuth login flow",
|
||||
"purpose_match": True,
|
||||
"risk_areas": ["Security", "Session management"],
|
||||
"red_flags": [],
|
||||
"requires_deep_verification": True,
|
||||
"complexity": "medium",
|
||||
}
|
||||
result = QuickScanResult.model_validate(data)
|
||||
assert result.purpose_match is True
|
||||
assert result.complexity == "medium"
|
||||
assert len(result.risk_areas) == 2
|
||||
|
||||
def test_complexity_values(self):
|
||||
"""Test all valid complexity values."""
|
||||
for complexity in ["low", "medium", "high"]:
|
||||
data = {
|
||||
"purpose": "Test",
|
||||
"actual_changes": "Test",
|
||||
"purpose_match": True,
|
||||
"requires_deep_verification": False,
|
||||
"complexity": complexity,
|
||||
}
|
||||
result = QuickScanResult.model_validate(data)
|
||||
assert result.complexity == complexity
|
||||
|
||||
|
||||
class TestSchemaGeneration:
|
||||
"""Tests for JSON schema generation."""
|
||||
|
||||
@@ -374,15 +246,6 @@ class TestSchemaGeneration:
|
||||
verdict_schema = schema["properties"]["verdict"]
|
||||
assert "enum" in verdict_schema or "$ref" in str(schema)
|
||||
|
||||
def test_orchestrator_schema_generation(self):
|
||||
"""Test that OrchestratorReviewResponse generates valid JSON schema."""
|
||||
schema = OrchestratorReviewResponse.model_json_schema()
|
||||
|
||||
assert "properties" in schema
|
||||
assert "verdict" in schema["properties"]
|
||||
assert "findings" in schema["properties"]
|
||||
assert "summary" in schema["properties"]
|
||||
|
||||
def test_schema_has_descriptions(self):
|
||||
"""Test that schema includes field descriptions for AI guidance."""
|
||||
schema = FollowupReviewResponse.model_json_schema()
|
||||
@@ -392,108 +255,8 @@ class TestSchemaGeneration:
|
||||
assert "properties" in schema or "$defs" in schema
|
||||
|
||||
|
||||
class TestSecurityFinding:
|
||||
"""Tests for SecurityFinding model."""
|
||||
|
||||
def test_security_category_default(self):
|
||||
"""Test that SecurityFinding has security category by default."""
|
||||
data = {
|
||||
"id": "sec-1",
|
||||
"severity": "high",
|
||||
"title": "XSS vulnerability",
|
||||
"description": "Unescaped user input",
|
||||
"file": "template.html",
|
||||
"line": 50,
|
||||
"verification": {
|
||||
"code_examined": "<div>{{ user_input }}</div>",
|
||||
"line_range_examined": [50, 50],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = SecurityFinding.model_validate(data)
|
||||
assert result.category == "security"
|
||||
|
||||
|
||||
class TestDeepAnalysisFinding:
|
||||
"""Tests for DeepAnalysisFinding model."""
|
||||
|
||||
def test_evidence_field(self):
|
||||
"""Test evidence field for proof of issue."""
|
||||
data = {
|
||||
"id": "deep-1",
|
||||
"severity": "medium",
|
||||
"title": "Potential race condition",
|
||||
"description": "Concurrent access without lock",
|
||||
"file": "worker.py",
|
||||
"line": 100,
|
||||
"category": "logic",
|
||||
"evidence": "shared_state += 1 # no lock protection",
|
||||
"verification": {
|
||||
"code_examined": "shared_state += 1 # no lock protection",
|
||||
"line_range_examined": [100, 100],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = DeepAnalysisFinding.model_validate(data)
|
||||
assert result.evidence == "shared_state += 1 # no lock protection"
|
||||
|
||||
def test_verification_note(self):
|
||||
"""Test verification note field."""
|
||||
data = {
|
||||
"id": "deep-2",
|
||||
"severity": "low",
|
||||
"title": "Unverified assumption",
|
||||
"description": "Could not verify behavior",
|
||||
"file": "lib.py",
|
||||
"category": "verification_failed",
|
||||
"verification_note": "Unable to find test coverage",
|
||||
"verification": {
|
||||
"code_examined": "def some_function():\n return process_data()",
|
||||
"line_range_examined": [1, 2],
|
||||
"verification_method": "cross_file_trace",
|
||||
},
|
||||
}
|
||||
result = DeepAnalysisFinding.model_validate(data)
|
||||
assert result.verification_note == "Unable to find test coverage"
|
||||
|
||||
|
||||
class TestAICommentTriage:
|
||||
"""Tests for AICommentTriage model."""
|
||||
|
||||
def test_valid_triage(self):
|
||||
"""Test valid AI comment triage."""
|
||||
data = {
|
||||
"comment_id": 12345,
|
||||
"tool_name": "CodeRabbit",
|
||||
"verdict": "important",
|
||||
"reasoning": "Valid security concern raised",
|
||||
"response_comment": "Thank you, we will address this.",
|
||||
}
|
||||
result = AICommentTriage.model_validate(data)
|
||||
assert result.comment_id == 12345
|
||||
assert result.verdict == "important"
|
||||
|
||||
def test_all_verdict_values(self):
|
||||
"""Test all valid triage verdict values."""
|
||||
for verdict in [
|
||||
"critical",
|
||||
"important",
|
||||
"nice_to_have",
|
||||
"trivial",
|
||||
"false_positive",
|
||||
]:
|
||||
data = {
|
||||
"comment_id": 1,
|
||||
"tool_name": "Test",
|
||||
"verdict": verdict,
|
||||
"reasoning": f"Testing {verdict}",
|
||||
}
|
||||
result = AICommentTriage.model_validate(data)
|
||||
assert result.verdict == verdict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Phase 2: Schema Enforcement Tests
|
||||
# Verification Evidence Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -570,10 +333,10 @@ class TestVerificationEvidence:
|
||||
|
||||
|
||||
class TestParallelOrchestratorFindingVerification:
|
||||
"""Tests for verification field requirement on ParallelOrchestratorFinding."""
|
||||
"""Tests for verification field on ParallelOrchestratorFinding."""
|
||||
|
||||
def test_missing_verification_rejected(self):
|
||||
"""Test that findings without verification are rejected."""
|
||||
def test_missing_verification_accepted(self):
|
||||
"""Test that findings without verification are accepted (now optional)."""
|
||||
data = {
|
||||
"id": "test-1",
|
||||
"file": "test.py",
|
||||
@@ -582,11 +345,10 @@ class TestParallelOrchestratorFindingVerification:
|
||||
"description": "A test finding without verification",
|
||||
"category": "quality",
|
||||
"severity": "medium",
|
||||
# No verification field - should fail
|
||||
# No verification field — should succeed (now optional)
|
||||
}
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ParallelOrchestratorFinding.model_validate(data)
|
||||
assert "verification" in str(exc_info.value)
|
||||
result = ParallelOrchestratorFinding.model_validate(data)
|
||||
assert result.verification is None
|
||||
|
||||
def test_valid_finding_with_verification(self):
|
||||
"""Test valid finding with verification evidence."""
|
||||
@@ -618,11 +380,6 @@ class TestParallelOrchestratorFindingVerification:
|
||||
"description": "Test",
|
||||
"category": "quality",
|
||||
"severity": "medium",
|
||||
"verification": {
|
||||
"code_examined": "code",
|
||||
"line_range_examined": [10, 10],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = ParallelOrchestratorFinding.model_validate(data)
|
||||
assert result.is_impact_finding is False
|
||||
@@ -657,11 +414,6 @@ class TestParallelOrchestratorFindingVerification:
|
||||
"description": "No try-catch",
|
||||
"category": "quality",
|
||||
"severity": "medium",
|
||||
"verification": {
|
||||
"code_examined": "code",
|
||||
"line_range_examined": [10, 10],
|
||||
"verification_method": "direct_code_inspection",
|
||||
},
|
||||
}
|
||||
result = ParallelOrchestratorFinding.model_validate(data)
|
||||
assert result.checked_for_handling_elsewhere is False
|
||||
@@ -686,6 +438,34 @@ class TestParallelOrchestratorFindingVerification:
|
||||
result = ParallelOrchestratorFinding.model_validate(data)
|
||||
assert result.checked_for_handling_elsewhere is True
|
||||
|
||||
def test_invalid_severity_normalized(self):
|
||||
"""Test invalid severity is normalized to 'medium'."""
|
||||
data = {
|
||||
"id": "test-1",
|
||||
"file": "test.py",
|
||||
"line": 10,
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"category": "quality",
|
||||
"severity": "super_critical",
|
||||
}
|
||||
result = ParallelOrchestratorFinding.model_validate(data)
|
||||
assert result.severity == "medium"
|
||||
|
||||
def test_invalid_category_normalized(self):
|
||||
"""Test invalid category is normalized to 'quality'."""
|
||||
data = {
|
||||
"id": "test-1",
|
||||
"file": "test.py",
|
||||
"line": 10,
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"category": "unknown_thing",
|
||||
"severity": "medium",
|
||||
}
|
||||
result = ParallelOrchestratorFinding.model_validate(data)
|
||||
assert result.category == "quality"
|
||||
|
||||
|
||||
class TestVerificationSchemaGeneration:
|
||||
"""Tests for JSON schema generation with VerificationEvidence."""
|
||||
@@ -713,3 +493,96 @@ class TestVerificationSchemaGeneration:
|
||||
|
||||
assert "is_impact_finding" in schema["properties"]
|
||||
assert "checked_for_handling_elsewhere" in schema["properties"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Specialist Finding Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSpecialistFinding:
|
||||
"""Tests for SpecialistFinding model."""
|
||||
|
||||
def test_empty_evidence_accepted(self):
|
||||
"""Test that empty evidence is accepted (no min_length)."""
|
||||
data = {
|
||||
"severity": "medium",
|
||||
"category": "quality",
|
||||
"title": "Test finding",
|
||||
"description": "A test",
|
||||
"file": "test.py",
|
||||
"evidence": "",
|
||||
}
|
||||
result = SpecialistFinding.model_validate(data)
|
||||
assert result.evidence == ""
|
||||
|
||||
def test_evidence_defaults_to_empty(self):
|
||||
"""Test that evidence defaults to empty string."""
|
||||
data = {
|
||||
"severity": "medium",
|
||||
"category": "quality",
|
||||
"title": "Test finding",
|
||||
"description": "A test",
|
||||
"file": "test.py",
|
||||
}
|
||||
result = SpecialistFinding.model_validate(data)
|
||||
assert result.evidence == ""
|
||||
|
||||
def test_invalid_severity_normalized(self):
|
||||
"""Test invalid severity is normalized."""
|
||||
data = {
|
||||
"severity": "urgent",
|
||||
"category": "security",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"file": "test.py",
|
||||
}
|
||||
result = SpecialistFinding.model_validate(data)
|
||||
assert result.severity == "medium"
|
||||
|
||||
def test_invalid_category_normalized(self):
|
||||
"""Test invalid category is normalized."""
|
||||
data = {
|
||||
"severity": "high",
|
||||
"category": "style",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"file": "test.py",
|
||||
}
|
||||
result = SpecialistFinding.model_validate(data)
|
||||
assert result.category == "quality"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Follow-up Finding Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestParallelFollowupFinding:
|
||||
"""Tests for ParallelFollowupFinding model."""
|
||||
|
||||
def test_invalid_severity_normalized(self):
|
||||
"""Test invalid severity is normalized."""
|
||||
data = {
|
||||
"id": "pf-1",
|
||||
"file": "test.py",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"category": "quality",
|
||||
"severity": "extreme",
|
||||
}
|
||||
result = ParallelFollowupFinding.model_validate(data)
|
||||
assert result.severity == "medium"
|
||||
|
||||
def test_invalid_category_normalized(self):
|
||||
"""Test invalid category is normalized."""
|
||||
data = {
|
||||
"id": "pf-1",
|
||||
"file": "test.py",
|
||||
"title": "Test",
|
||||
"description": "Test",
|
||||
"category": "unknown",
|
||||
"severity": "medium",
|
||||
}
|
||||
result = ParallelFollowupFinding.model_validate(data)
|
||||
assert result.category == "quality"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user