Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04618ccb08 | |||
| e17137e937 | |||
| 9e276c4757 | |||
| d745aee48d | |||
| 5495e6564e | |||
| f399cd8ce2 | |||
| d98ff7d19c | |||
| 635b53eeaf | |||
| 2e4b5ac659 | |||
| 385f044144 | |||
| 7b0f3a2c03 | |||
| 3a7c4ca7a9 | |||
| 4091d1d4b5 | |||
| f40f79a2db | |||
| 603b9a24bf | |||
| ecb6158024 | |||
| ae13ce14c2 | |||
| e3b219288e | |||
| 6204d5fc2b | |||
| f735f0b49b | |||
| a4870fa0c3 | |||
| f1b8cd3a7a | |||
| 4d4234378f | |||
| d1fbccde39 | |||
| ed93df698b | |||
| 8872d33e32 | |||
| 3b3ad75c1b | |||
| 8ece0009ee | |||
| 115576e85d |
+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.3)
|
||||
[](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.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-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.3"
|
||||
__version__ = "2.7.6-beta.5"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -292,6 +292,14 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_extraction": {
|
||||
# Lightweight extraction call for recovering data when structured output fails
|
||||
# Pure structured output extraction, no tools needed
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -186,14 +186,12 @@ def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
|
||||
def init_sentry(
|
||||
component: str = "backend",
|
||||
force_enable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize Sentry for the Python backend.
|
||||
|
||||
Args:
|
||||
component: Component name for tagging (e.g., "backend", "github-runner")
|
||||
force_enable: Force enable even without packaged app detection
|
||||
|
||||
Returns:
|
||||
True if Sentry was initialized, False otherwise
|
||||
@@ -212,20 +210,11 @@ def init_sentry(
|
||||
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
|
||||
return False
|
||||
|
||||
# Check if we should enable Sentry
|
||||
# Enable if:
|
||||
# - Running from packaged app (detected by __compiled__ or frozen)
|
||||
# - SENTRY_DEV=true is set
|
||||
# - force_enable is True
|
||||
# DSN is present (checked above), so Sentry should be enabled.
|
||||
# The Electron main process only passes SENTRY_DSN to subprocesses in
|
||||
# production builds, so its presence is sufficient to gate activation.
|
||||
# In dev, set SENTRY_DSN in your environment to opt-in.
|
||||
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
should_enable = is_packaged or sentry_dev or force_enable
|
||||
|
||||
if not should_enable:
|
||||
logger.debug(
|
||||
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
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**: Must be recreated. Python's ``pyvenv.cfg`` discovery walks the
|
||||
real directory hierarchy without resolving symlinks (CPython bug #106045), so a
|
||||
symlinked venv resolves paths relative to the *target*, not the worktree.
|
||||
|
||||
- **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 — venvs MUST be recreated (pyvenv.cfg symlink bug)
|
||||
"venv": DependencyStrategy.RECREATE,
|
||||
".venv": DependencyStrategy.RECREATE,
|
||||
# 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,31 @@ class SpecNumberLock:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
|
||||
class DependencyStrategy(Enum):
|
||||
"""Strategy for sharing dependency directories across worktrees.
|
||||
|
||||
SYMLINK is fast but unsafe for certain ecosystems. Notably, Python venv
|
||||
breaks when symlinked because CPython's pyvenv.cfg discovery walks the
|
||||
real directory hierarchy without resolving symlinks first
|
||||
(CPython bug #106045). This means a symlinked venv resolves its home
|
||||
path relative to the symlink target's parent, not the worktree, causing
|
||||
import failures and broken interpreters.
|
||||
"""
|
||||
|
||||
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:
|
||||
@@ -189,11 +191,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 +232,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 +373,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 +593,299 @@ 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 = True
|
||||
if config.strategy == DependencyStrategy.SYMLINK:
|
||||
performed = _apply_symlink_strategy(project_dir, worktree_path, config)
|
||||
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 _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
|
||||
|
||||
if venv_path.exists():
|
||||
debug(MODULE, f"Skipping recreate {config.source_rel_path} - already exists")
|
||||
return False
|
||||
|
||||
# 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}")
|
||||
result = subprocess.run(
|
||||
[python_exec, "-m", "venv", str(venv_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
debug_warning(MODULE, f"venv creation failed: {result.stderr}")
|
||||
print_status(
|
||||
f"Warning: Could not create venv at {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(MODULE, f"venv creation timed out for {config.source_rel_path}")
|
||||
print_status(
|
||||
f"Warning: venv creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
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}")
|
||||
pip_result = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if pip_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install failed (exit {pip_result.returncode}): "
|
||||
f"{pip_result.stderr}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install failed for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install timed out for {req_file}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install timed out for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
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}")
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,6 +22,11 @@ except (ImportError, ValueError, SystemError):
|
||||
from file_lock import locked_json_update, locked_json_write
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
"""Return current UTC time as ISO 8601 string with timezone info."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class ReviewSeverity(str, Enum):
|
||||
"""Severity levels for PR review findings."""
|
||||
|
||||
@@ -521,7 +526,7 @@ class PRReviewResult:
|
||||
summary: str = ""
|
||||
overall_status: str = "comment" # approve, request_changes, comment
|
||||
review_id: int | None = None
|
||||
reviewed_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
reviewed_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
error: str | None = None
|
||||
|
||||
# NEW: Enhanced verdict system
|
||||
@@ -567,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,
|
||||
@@ -598,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
|
||||
@@ -610,7 +620,7 @@ class PRReviewResult:
|
||||
summary=data.get("summary", ""),
|
||||
overall_status=data.get("overall_status", "comment"),
|
||||
review_id=data.get("review_id"),
|
||||
reviewed_at=data.get("reviewed_at", datetime.now().isoformat()),
|
||||
reviewed_at=data.get("reviewed_at", _utc_now_iso()),
|
||||
error=data.get("error"),
|
||||
# NEW fields
|
||||
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
|
||||
@@ -645,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:
|
||||
@@ -691,7 +703,7 @@ class PRReviewResult:
|
||||
reviews.append(entry)
|
||||
|
||||
current_data["reviews"] = reviews
|
||||
current_data["last_updated"] = datetime.now().isoformat()
|
||||
current_data["last_updated"] = _utc_now_iso()
|
||||
|
||||
return current_data
|
||||
|
||||
@@ -762,7 +774,7 @@ class TriageResult:
|
||||
suggested_breakdown: list[str] = field(default_factory=list)
|
||||
priority: str = "medium" # high, medium, low
|
||||
comment: str | None = None
|
||||
triaged_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
triaged_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -798,7 +810,7 @@ class TriageResult:
|
||||
suggested_breakdown=data.get("suggested_breakdown", []),
|
||||
priority=data.get("priority", "medium"),
|
||||
comment=data.get("comment"),
|
||||
triaged_at=data.get("triaged_at", datetime.now().isoformat()),
|
||||
triaged_at=data.get("triaged_at", _utc_now_iso()),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
@@ -836,8 +848,8 @@ class AutoFixState:
|
||||
pr_url: str | None = None
|
||||
bot_comments: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
created_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
updated_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -875,8 +887,8 @@ class AutoFixState:
|
||||
pr_url=data.get("pr_url"),
|
||||
bot_comments=data.get("bot_comments", []),
|
||||
error=data.get("error"),
|
||||
created_at=data.get("created_at", datetime.now().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now().isoformat()),
|
||||
created_at=data.get("created_at", _utc_now_iso()),
|
||||
updated_at=data.get("updated_at", _utc_now_iso()),
|
||||
)
|
||||
|
||||
def update_status(self, status: AutoFixStatus) -> None:
|
||||
@@ -886,7 +898,7 @@ class AutoFixState:
|
||||
f"Invalid state transition: {self.status.value} -> {status.value}"
|
||||
)
|
||||
self.status = status
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
self.updated_at = _utc_now_iso()
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
"""Save auto-fix state to .auto-claude/github/issues/ with file locking."""
|
||||
@@ -938,7 +950,7 @@ class AutoFixState:
|
||||
queue.append(entry)
|
||||
|
||||
current_data["auto_fix_queue"] = queue
|
||||
current_data["last_updated"] = datetime.now().isoformat()
|
||||
current_data["last_updated"] = _utc_now_iso()
|
||||
|
||||
return current_data
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -26,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,
|
||||
@@ -33,12 +34,16 @@ try:
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
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,
|
||||
@@ -46,11 +51,18 @@ except (ImportError, ValueError, SystemError):
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
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__)
|
||||
|
||||
@@ -265,7 +277,7 @@ class FollowupReviewer:
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
reviewed_at=_utc_now_iso(),
|
||||
# Follow-up specific fields
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
reviewed_file_blobs=file_blobs,
|
||||
@@ -697,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(
|
||||
@@ -721,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
|
||||
@@ -764,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:
|
||||
@@ -839,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],
|
||||
|
||||
@@ -51,7 +51,8 @@ try:
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
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
|
||||
@@ -75,7 +76,11 @@ except (ImportError, ValueError, SystemError):
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.pydantic_models import (
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from services.recovery_utils import create_finding_from_summary
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
@@ -576,16 +581,36 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
stream_error = stream_result.get("error")
|
||||
if stream_error:
|
||||
if stream_result.get("error_recoverable"):
|
||||
# Recoverable error — attempt extraction call fallback
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Recoverable error: {stream_error}. "
|
||||
f"Attempting extraction call fallback."
|
||||
)
|
||||
safe_print(
|
||||
f"[ParallelFollowup] WARNING: {stream_error} — "
|
||||
f"attempting recovery with minimal extraction...",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
# Fatal error — raise as before
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_error}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
last_assistant_text = stream_result.get("last_assistant_text", "")
|
||||
# Nullify structured output on recoverable errors to force Tier 2 fallback
|
||||
structured_output = (
|
||||
None
|
||||
if (stream_error and stream_result.get("error_recoverable"))
|
||||
else stream_result["structured_output"]
|
||||
)
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
|
||||
@@ -596,22 +621,28 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output
|
||||
# Parse findings from output (three-tier recovery cascade)
|
||||
if structured_output:
|
||||
result_data = self._parse_structured_output(structured_output, context)
|
||||
else:
|
||||
# Log when structured output is missing - this shouldn't happen normally
|
||||
# when output_format is configured, so it indicates a problem
|
||||
# Structured output missing or validation failed.
|
||||
# Tier 2: Attempt extraction call with minimal schema
|
||||
logger.warning(
|
||||
"[ParallelFollowup] No structured output received from SDK - "
|
||||
"falling back to text parsing. Resolution data may be incomplete."
|
||||
"[ParallelFollowup] No structured output — attempting extraction call"
|
||||
)
|
||||
safe_print(
|
||||
"[ParallelFollowup] WARNING: Structured output not captured, "
|
||||
"using text fallback (resolution tracking may be incomplete)",
|
||||
flush=True,
|
||||
# Use last_assistant_text (cleaner) if available, fall back to full transcript
|
||||
fallback_text = last_assistant_text or result_text
|
||||
result_data = await self._attempt_extraction_call(
|
||||
fallback_text, context
|
||||
)
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
if result_data is None:
|
||||
# Tier 3: Fall back to basic text parsing
|
||||
safe_print(
|
||||
"[ParallelFollowup] WARNING: Extraction call failed, "
|
||||
"using text fallback (resolution tracking may be incomplete)",
|
||||
flush=True,
|
||||
)
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
|
||||
# Extract data
|
||||
findings = result_data.get("findings", [])
|
||||
@@ -730,7 +761,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Extract validation counts
|
||||
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
|
||||
dismissed_count = len(
|
||||
result_data.get("dismissed_false_positive_ids", [])
|
||||
) or result_data.get("dismissed_finding_count", 0)
|
||||
confirmed_count = result_data.get("confirmed_valid_count", 0)
|
||||
needs_human_count = result_data.get("needs_human_review_count", 0)
|
||||
|
||||
@@ -1074,17 +1107,172 @@ The SDK will run invoked agents in parallel automatically.
|
||||
elif "needs revision" in text_lower or "request changes" in text_lower:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
else:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": [],
|
||||
"unresolved_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": text[:500] if text else "Unable to parse response",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
async def _attempt_extraction_call(
|
||||
self, text: str, context: FollowupReviewContext
|
||||
) -> dict | None:
|
||||
"""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 (small schema with ExtractedFindingSummary nesting)
|
||||
which has near-100% success rate.
|
||||
|
||||
Returns parsed result dict on success, None on failure.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
logger.warning("[ParallelFollowup] No text available for extraction call")
|
||||
return None
|
||||
|
||||
try:
|
||||
safe_print(
|
||||
"[ParallelFollowup] Attempting recovery with minimal extraction schema...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
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",
|
||||
fast_mode=self.config.fast_mode,
|
||||
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"[ParallelFollowup] Extraction call also failed: {stream_result['error']}"
|
||||
)
|
||||
return None
|
||||
|
||||
extraction_output = stream_result.get("structured_output")
|
||||
if not extraction_output:
|
||||
logger.warning(
|
||||
"[ParallelFollowup] Extraction call returned no structured output"
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse the minimal extraction response
|
||||
extracted = FollowupExtractionResponse.model_validate(extraction_output)
|
||||
|
||||
# Map verdict string to MergeVerdict enum
|
||||
verdict_map = {
|
||||
"READY_TO_MERGE": MergeVerdict.READY_TO_MERGE,
|
||||
"MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"NEEDS_REVISION": MergeVerdict.NEEDS_REVISION,
|
||||
"BLOCKED": MergeVerdict.BLOCKED,
|
||||
}
|
||||
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.unresolved_finding_ids)} unresolved, "
|
||||
f"{len(new_finding_ids)} new findings, "
|
||||
f"{len(findings)} total findings reconstructed",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": extracted.resolved_finding_ids,
|
||||
"unresolved_ids": extracted.unresolved_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,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": f"[Recovered via extraction] {extracted.verdict_reasoning}",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[ParallelFollowup] Extraction call failed: {e}")
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction call failed: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _create_empty_result(self) -> dict:
|
||||
"""Create empty result structure."""
|
||||
return {
|
||||
@@ -1092,8 +1280,13 @@ The SDK will run invoked agents in parallel automatically.
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": 0,
|
||||
"dismissed_finding_count": 0,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": MergeVerdict.NEEDS_REVISION,
|
||||
"verdict_reasoning": "Unable to parse review results",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
def _extract_partial_data(self, data: dict) -> dict | None:
|
||||
@@ -1102,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
|
||||
@@ -1109,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", [])
|
||||
@@ -1127,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")
|
||||
@@ -1149,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,
|
||||
@@ -1785,6 +1869,7 @@ For EACH finding above:
|
||||
or "concurrency" in error_str
|
||||
or "circuit breaker" in error_str
|
||||
or "tool_use" in error_str
|
||||
or "structured_output" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
@@ -1805,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
|
||||
@@ -1869,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
|
||||
@@ -2059,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
|
||||
@@ -2093,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}")
|
||||
@@ -2114,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.
|
||||
@@ -710,3 +526,55 @@ class FindingValidationResponse(BaseModel):
|
||||
"how many dismissed, how many need human review"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Minimal Extraction Schema (Fallback for structured output validation failure)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Uses ExtractedFindingSummary for new findings to preserve file/line information.
|
||||
Used as an intermediate recovery step before falling back to raw text parsing.
|
||||
"""
|
||||
|
||||
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")
|
||||
resolved_finding_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that are now resolved",
|
||||
)
|
||||
unresolved_finding_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that remain unresolved",
|
||||
)
|
||||
new_finding_summaries: list[ExtractedFindingSummary] = Field(
|
||||
default_factory=list,
|
||||
description="Structured summary of each new finding with file location",
|
||||
)
|
||||
confirmed_finding_count: int = Field(
|
||||
0, description="Number of findings confirmed as valid"
|
||||
)
|
||||
dismissed_finding_count: int = Field(
|
||||
0, description="Number of findings dismissed as false positives"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -133,6 +133,13 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
# Prevents runaway retry loops from consuming unbounded resources
|
||||
MAX_MESSAGE_COUNT = 500
|
||||
|
||||
# Errors that are recoverable (callers can fall back to text parsing or retry)
|
||||
# vs fatal errors (auth failures, circuit breaker) that should propagate
|
||||
RECOVERABLE_ERRORS = {
|
||||
"structured_output_validation_failed",
|
||||
"tool_use_concurrency_error",
|
||||
}
|
||||
|
||||
# Abort after 1 consecutive repeat (2 total identical responses).
|
||||
# Low threshold catches error loops quickly (e.g., auth errors returned as AI text).
|
||||
# Normal AI responses never produce the exact same text block twice in a row.
|
||||
@@ -261,8 +268,11 @@ async def process_sdk_stream(
|
||||
- msg_count: Total message count
|
||||
- subagent_tool_ids: Mapping of tool_id -> agent_name
|
||||
- error: Error message if stream processing failed (None on success)
|
||||
- error_recoverable: Boolean indicating if the error is recoverable (fallback possible) vs fatal
|
||||
- last_assistant_text: Last non-empty assistant text block (for cleaner fallback parsing)
|
||||
"""
|
||||
result_text = ""
|
||||
last_assistant_text = "" # Last assistant text block (for cleaner fallback parsing)
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
@@ -481,6 +491,9 @@ async def process_sdk_stream(
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Track last non-empty text for fallback parsing
|
||||
if block.text.strip():
|
||||
last_assistant_text = block.text
|
||||
# Check for auth/access error returned as AI response text.
|
||||
# Note: break exits this inner for-loop over msg.content;
|
||||
# the outer message loop exits via `if stream_error: break`.
|
||||
@@ -647,11 +660,16 @@ async def process_sdk_stream(
|
||||
f"[{context_name}] Tool use concurrency error detected - caller should retry"
|
||||
)
|
||||
|
||||
# Categorize error as recoverable (fallback possible) vs fatal
|
||||
error_recoverable = stream_error in RECOVERABLE_ERRORS if stream_error else False
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"last_assistant_text": last_assistant_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
"msg_count": msg_count,
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
"error_recoverable": error_recoverable,
|
||||
}
|
||||
|
||||
@@ -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.3",
|
||||
"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;
|
||||
|
||||
@@ -38,6 +38,18 @@ log.transports.file.fileName = 'main.log';
|
||||
// Console transport - always show warnings and errors, debug only in dev mode
|
||||
log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'warn';
|
||||
log.transports.console.format = '[{h}:{i}:{s}] [{level}] {text}';
|
||||
// Guard console transport writes so broken stdio streams do not crash the app.
|
||||
{
|
||||
const originalConsoleWriteFn = log.transports.console.writeFn as (...args: unknown[]) => void;
|
||||
log.transports.console.writeFn = (...args: unknown[]) => {
|
||||
try {
|
||||
originalConsoleWriteFn(...args);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
safeStderrWrite(`[app-logger] console transport write failed: ${err}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if this is a beta version
|
||||
function isBetaVersion(): boolean {
|
||||
@@ -204,14 +216,44 @@ export const appLog = {
|
||||
log: (...args: unknown[]) => log.info(...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort stderr fallback used when electron-log itself throws (e.g. EIO).
|
||||
* Must never throw, especially inside uncaught exception handlers.
|
||||
*/
|
||||
function safeStderrWrite(message: string): void {
|
||||
try {
|
||||
process.stderr.write(`${message}\n`);
|
||||
} catch {
|
||||
// Ignore - nothing else we can safely do here.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an unhandled error without risking recursive crashes if logger transport fails.
|
||||
*/
|
||||
function safeLogUnhandled(prefix: string, value: unknown): void {
|
||||
try {
|
||||
log.error(prefix, value);
|
||||
} catch (loggingError) {
|
||||
const loggingFailure = loggingError instanceof Error
|
||||
? `${loggingError.name}: ${loggingError.message}`
|
||||
: String(loggingError);
|
||||
const original = value instanceof Error
|
||||
? (value.stack || `${value.name}: ${value.message}`)
|
||||
: String(value);
|
||||
safeStderrWrite(`[app-logger] ${prefix} (logger failed: ${loggingFailure})`);
|
||||
safeStderrWrite(original);
|
||||
}
|
||||
}
|
||||
|
||||
// Log unhandled errors
|
||||
export function setupErrorLogging(): void {
|
||||
process.on('uncaughtException', (error) => {
|
||||
log.error('Uncaught exception:', error);
|
||||
safeLogUnhandled('Uncaught exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error('Unhandled rejection:', reason);
|
||||
safeLogUnhandled('Unhandled rejection:', reason);
|
||||
});
|
||||
|
||||
log.info('Error logging initialized');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -49,7 +49,7 @@ import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater';
|
||||
import { DEFAULT_APP_SETTINGS, IPC_CHANNELS, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, ADD_TO_DICTIONARY_LABELS } from '../shared/constants';
|
||||
import { getAppLanguage, initAppLanguage } from './app-language';
|
||||
import { readSettingsFile } from './settings-utils';
|
||||
import { setupErrorLogging } from './app-logger';
|
||||
import { appLog, setupErrorLogging } from './app-logger';
|
||||
import { initSentryMain } from './sentry';
|
||||
import { preWarmToolCache } from './cli-tool-manager';
|
||||
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
|
||||
@@ -143,6 +143,11 @@ let mainWindow: BrowserWindow | null = null;
|
||||
let agentManager: AgentManager | null = null;
|
||||
let terminalManager: TerminalManager | null = null;
|
||||
|
||||
// Capture child process exits (renderer/GPU/utility) for crash diagnostics.
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
appLog.error('[main] child-process-gone:', details);
|
||||
});
|
||||
|
||||
// Re-entrancy guard for before-quit handler.
|
||||
// The first before-quit call pauses quit for async cleanup, then calls app.quit() again.
|
||||
// The second call sees isQuitting=true and allows quit to proceed immediately.
|
||||
@@ -214,6 +219,11 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Capture renderer process crashes/termination reasons for diagnostics.
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
appLog.error('[main] render-process-gone:', details);
|
||||
});
|
||||
|
||||
// Configure initial spell check languages with proper fallback logic
|
||||
// Uses shared constant for consistency with the IPC handler
|
||||
const defaultLanguage = 'en';
|
||||
@@ -330,6 +340,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
buildRunnerArgs,
|
||||
} from "./utils/subprocess-runner";
|
||||
import { getPRStatusPoller } from "../../services/pr-status-poller";
|
||||
import { safeBreadcrumb, safeCaptureException } from "../../sentry";
|
||||
import { sanitizeForSentry } from "../../../shared/utils/sentry-privacy";
|
||||
import type {
|
||||
StartPollingRequest,
|
||||
StopPollingRequest,
|
||||
@@ -110,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: {
|
||||
@@ -265,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,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;
|
||||
@@ -292,6 +299,8 @@ export interface PRReviewResult {
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
// In-progress review tracking
|
||||
inProgressSince?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1336,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",
|
||||
@@ -1354,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
|
||||
@@ -1462,6 +1477,20 @@ async function runPRReview(
|
||||
|
||||
debugLog("Spawning PR review process", { args, model, thinkingLevel });
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: 'Spawning PR review subprocess',
|
||||
level: 'info',
|
||||
data: {
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
runnerPath: getRunnerPath(backendPath),
|
||||
cwd: backendPath,
|
||||
model,
|
||||
thinkingLevel,
|
||||
prNumber,
|
||||
},
|
||||
});
|
||||
|
||||
// Create log collector for this review
|
||||
const config = getGitHubConfig(project);
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
@@ -1498,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) {
|
||||
@@ -1525,9 +1579,22 @@ async function runPRReview(
|
||||
// Wait for the process to complete
|
||||
const result = await promise;
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: `PR review subprocess exited`,
|
||||
level: result.success ? 'info' : 'error',
|
||||
data: { exitCode: result.exitCode, success: result.success, prNumber },
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Finalize logs with failure
|
||||
logCollector.finalize(false);
|
||||
|
||||
safeCaptureException(
|
||||
new Error(`PR review subprocess failed: ${result.error ?? 'unknown error'}`),
|
||||
{ extra: { exitCode: result.exitCode, prNumber, stderr: sanitizeForSentry(result.stderr.slice(0, 500)) } }
|
||||
);
|
||||
|
||||
throw new Error(result.error ?? "Review failed");
|
||||
}
|
||||
|
||||
@@ -1835,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;
|
||||
}
|
||||
|
||||
@@ -1907,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",
|
||||
@@ -2907,6 +2994,20 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
debugLog("Spawning follow-up review process", { args, model, thinkingLevel });
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: 'Spawning follow-up PR review subprocess',
|
||||
level: 'info',
|
||||
data: {
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
runnerPath: getRunnerPath(backendPath),
|
||||
cwd: backendPath,
|
||||
model,
|
||||
thinkingLevel,
|
||||
prNumber,
|
||||
},
|
||||
});
|
||||
|
||||
// Create log collector for this follow-up review (config already declared above)
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, true, mainWindow);
|
||||
@@ -2964,9 +3065,22 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
const result = await promise;
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: 'Follow-up PR review subprocess exited',
|
||||
level: result.success ? 'info' : 'error',
|
||||
data: { exitCode: result.exitCode, success: result.success, prNumber },
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Finalize logs with failure
|
||||
logCollector.finalize(false);
|
||||
|
||||
safeCaptureException(
|
||||
new Error(`Follow-up PR review subprocess failed: ${result.error ?? 'unknown error'}`),
|
||||
{ extra: { exitCode: result.exitCode, prNumber, stderr: sanitizeForSentry(result.stderr.slice(0, 500)) } }
|
||||
);
|
||||
|
||||
throw new Error(result.error ?? "Follow-up review failed");
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getAPIProfileEnv } from '../../../services/profile';
|
||||
import { getBestAvailableProfileEnv } from '../../../rate-limit-detector';
|
||||
import { pythonEnvManager } from '../../../python-env-manager';
|
||||
import { getGitHubTokenForSubprocess } from '../utils';
|
||||
import { getSentryEnvForSubprocess } from '../../../sentry';
|
||||
|
||||
/**
|
||||
* Get environment variables for Python runner subprocesses.
|
||||
@@ -48,6 +49,7 @@ export async function getRunnerEnv(
|
||||
...oauthModeClearVars,
|
||||
...profileEnv, // OAuth token from profile manager (fixes #563, rate-limit aware)
|
||||
...githubEnv, // Fresh GitHub token from gh CLI (fixes #151)
|
||||
...extraEnv,
|
||||
...getSentryEnvForSubprocess(), // Sentry DSN + sample rates for Python subprocess
|
||||
...extraEnv, // extraEnv last so callers can still override
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isWindows, isMacOS } from '../../../platform';
|
||||
import { getEffectiveSourcePath } from '../../../updater/path-resolver';
|
||||
import { pythonEnvManager, getConfiguredPythonPath } from '../../../python-env-manager';
|
||||
import { getTaskkillExePath, getWhereExePath } from '../../../utils/windows-paths';
|
||||
import { safeCaptureException } from '../../../sentry';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -214,6 +215,17 @@ export function runPythonSubprocess<T = unknown>(
|
||||
let killedDueToAuthFailure = false; // Track if subprocess was killed due to auth failure
|
||||
let billingFailureEmitted = false; // Track if we've already emitted a billing failure
|
||||
let killedDueToBillingFailure = false; // Track if subprocess was killed due to billing failure
|
||||
let receivedOutput = false; // Track if any stdout/stderr has been received
|
||||
|
||||
// Health-check: report to Sentry if no output received within 120 seconds
|
||||
const healthCheckTimeout = setTimeout(() => {
|
||||
if (!receivedOutput) {
|
||||
safeCaptureException(
|
||||
new Error('[SubprocessRunner] No output received from subprocess after 120s'),
|
||||
{ extra: { pythonPath: options.pythonPath, args: options.args, cwd: options.cwd, envKeys: options.env ? Object.keys(options.env) : [] } }
|
||||
);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Default progress pattern: [ 30%] message OR [30%] message
|
||||
const progressPattern = options.progressPattern ?? /\[\s*(\d+)%\]\s*(.+)/;
|
||||
@@ -337,6 +349,7 @@ export function runPythonSubprocess<T = unknown>(
|
||||
};
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
receivedOutput = true;
|
||||
const text = data.toString('utf-8');
|
||||
stdout += text;
|
||||
|
||||
@@ -364,6 +377,7 @@ export function runPythonSubprocess<T = unknown>(
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
receivedOutput = true;
|
||||
const text = data.toString('utf-8');
|
||||
stderr += text;
|
||||
|
||||
@@ -382,6 +396,7 @@ export function runPythonSubprocess<T = unknown>(
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
clearTimeout(healthCheckTimeout);
|
||||
// Treat null exit code (killed with SIGKILL) as failure, not success
|
||||
const exitCode = code ?? -1;
|
||||
|
||||
@@ -461,6 +476,7 @@ export function runPythonSubprocess<T = unknown>(
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
clearTimeout(healthCheckTimeout);
|
||||
options.onError?.(err.message);
|
||||
resolve({
|
||||
success: false,
|
||||
|
||||
@@ -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 } from 'fs';
|
||||
import { execFileSync, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { minimatch } from 'minimatch';
|
||||
@@ -226,87 +226,405 @@ 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 — venvs MUST be recreated, not symlinked.
|
||||
// CPython bug #106045: pyvenv.cfg discovery does not resolve symlinks,
|
||||
// so a symlinked venv resolves paths relative to the target, not the worktree.
|
||||
venv: 'recreate',
|
||||
'.venv': 'recreate',
|
||||
// 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);
|
||||
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
|
||||
try {
|
||||
lstatSync(targetPath);
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists (possibly broken symlink)');
|
||||
return false;
|
||||
} catch {
|
||||
// Target doesn't exist at all — good, we can create symlink
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply recreate strategy: create a fresh virtual environment in the worktree.
|
||||
*
|
||||
* Python venvs cannot be symlinked due to CPython bug #106045 — pyvenv.cfg
|
||||
* discovery does not resolve symlinks, so paths resolve relative to the
|
||||
* symlink target instead of the worktree.
|
||||
*/
|
||||
async function applyRecreateStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): Promise<boolean> {
|
||||
const venvPath = path.join(worktreePath, config.sourceRelPath);
|
||||
|
||||
if (existsSync(venvPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping recreate', config.sourceRelPath, '- already exists');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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: 120000,
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +834,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);
|
||||
|
||||
@@ -753,6 +753,8 @@ if sys.version_info >= (3, 12):
|
||||
...windowsEnv,
|
||||
// Don't write bytecode - not needed and avoids permission issues
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
// Force unbuffered stdout/stderr so progress updates reach Electron immediately
|
||||
PYTHONUNBUFFERED: '1',
|
||||
// Use UTF-8 encoding
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,5 @@ export function getSentryEnvForSubprocess(): Record<string, string> {
|
||||
SENTRY_DSN: dsn,
|
||||
SENTRY_TRACES_SAMPLE_RATE: String(getTracesSampleRate()),
|
||||
SENTRY_PROFILES_SAMPLE_RATE: String(getProfilesSampleRate()),
|
||||
// Pass SENTRY_DEV so Python backend also enables Sentry in dev mode
|
||||
...(process.env.SENTRY_DEV ? { SENTRY_DEV: process.env.SENTRY_DEV } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,8 +195,20 @@ export class TerminalSessionStore {
|
||||
* 1. Write to temp file
|
||||
* 2. Rotate current file to backup
|
||||
* 3. Rename temp to target (atomic on most filesystems)
|
||||
*
|
||||
* If an async write is in progress, defers to the async writer to avoid
|
||||
* both operations competing for the same temp file (ENOENT race condition).
|
||||
*/
|
||||
private save(): void {
|
||||
// If an async write is in progress, don't write synchronously — the async
|
||||
// writer shares the same temp file path. Instead, mark a pending write so
|
||||
// saveAsync() will re-save with the latest in-memory data when it finishes.
|
||||
if (this.writeInProgress) {
|
||||
this.writePending = true;
|
||||
debugLog('[TerminalSessionStore] Deferring sync save — async write in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = JSON.stringify(this.data, null, 2);
|
||||
|
||||
@@ -366,7 +378,9 @@ export class TerminalSessionStore {
|
||||
private updateSessionInMemory(session: TerminalSession): boolean {
|
||||
// Check if session was deleted - skip if pending deletion
|
||||
if (this.pendingDelete.has(session.id)) {
|
||||
console.warn('[TerminalSessionStore] Skipping save for deleted session:', session.id);
|
||||
debugLog('[TerminalSessionStore] Skipping save for deleted session:', session.id,
|
||||
'pendingDelete size:', this.pendingDelete.size,
|
||||
'all pending IDs:', [...this.pendingDelete].join(', '));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -596,6 +610,27 @@ export class TerminalSessionStore {
|
||||
return sessions.find(s => s.id === sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a session ID from pendingDelete, allowing saves to proceed.
|
||||
*
|
||||
* Called when a terminal is legitimately re-created with the same ID
|
||||
* (e.g., worktree switching, terminal restart after exit). Without this,
|
||||
* the 5-second pendingDelete window blocks session persistence for the
|
||||
* new terminal.
|
||||
*/
|
||||
clearPendingDelete(sessionId: string): void {
|
||||
if (this.pendingDelete.has(sessionId)) {
|
||||
this.pendingDelete.delete(sessionId);
|
||||
// Also clear the cleanup timer since we're explicitly clearing
|
||||
const timer = this.pendingDeleteTimers.get(sessionId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.pendingDeleteTimers.delete(sessionId);
|
||||
}
|
||||
debugLog('[TerminalSessionStore] Cleared pendingDelete for re-created terminal:', sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session (from today's sessions)
|
||||
*
|
||||
@@ -606,6 +641,9 @@ export class TerminalSessionStore {
|
||||
// Mark as pending delete BEFORE modifying data to prevent race condition
|
||||
// with in-flight saveSessionAsync() calls
|
||||
this.pendingDelete.add(sessionId);
|
||||
debugLog('[TerminalSessionStore] removeSession: added to pendingDelete:', sessionId,
|
||||
'pendingDelete size:', this.pendingDelete.size,
|
||||
'all pending IDs:', [...this.pendingDelete].join(', '));
|
||||
|
||||
const todaySessions = this.getTodaysSessions();
|
||||
if (todaySessions[projectPath]) {
|
||||
@@ -627,6 +665,8 @@ export class TerminalSessionStore {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingDelete.delete(sessionId);
|
||||
this.pendingDeleteTimers.delete(sessionId);
|
||||
debugLog('[TerminalSessionStore] Cleanup timer fired for:', sessionId,
|
||||
'removing from pendingDelete. Remaining:', this.pendingDelete.size);
|
||||
}, 5000);
|
||||
this.pendingDeleteTimers.set(sessionId, timer);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,16 @@ function mockPlatform(platform: 'win32' | 'darwin' | 'linux') {
|
||||
/**
|
||||
* Helper to get platform-specific expectations for PATH prefix
|
||||
*/
|
||||
function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathValue: string): string {
|
||||
function getPathPrefixExpectation(
|
||||
platform: 'win32' | 'darwin' | 'linux',
|
||||
pathValue: string,
|
||||
command: string
|
||||
): string {
|
||||
// Absolute executable commands no longer need PATH prefix injection.
|
||||
if (path.isAbsolute(command)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Windows: set "PATH=value" &&
|
||||
return `set "PATH=${pathValue}" && `;
|
||||
@@ -118,6 +127,20 @@ function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathVa
|
||||
return `PATH='${pathValue}' `;
|
||||
}
|
||||
|
||||
function expectPathPrefix(
|
||||
written: string,
|
||||
platform: 'win32' | 'darwin' | 'linux',
|
||||
pathValue: string,
|
||||
command: string
|
||||
): void {
|
||||
const expectedPrefix = getPathPrefixExpectation(platform, pathValue, command);
|
||||
if (expectedPrefix) {
|
||||
expect(written).toContain(expectedPrefix);
|
||||
} else {
|
||||
expect(written).not.toContain('PATH=');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get platform-specific expectations for command quoting
|
||||
*/
|
||||
@@ -241,7 +264,7 @@ describe('claude-integration-handler', () => {
|
||||
|
||||
const written = mockWriteToPty.mock.calls[0][1] as string;
|
||||
expect(written).toContain(buildCdCommand('/tmp/project'));
|
||||
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
|
||||
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', "/opt/claude bin/claude's");
|
||||
expect(written).toContain(getQuotedCommand(platform, "/opt/claude bin/claude's"));
|
||||
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
@@ -402,7 +425,7 @@ describe('claude-integration-handler', () => {
|
||||
|
||||
expect(written).toContain(histPrefix);
|
||||
expect(written).toContain(configDir);
|
||||
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
|
||||
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', command);
|
||||
expect(written).toContain(getQuotedCommand(platform, command));
|
||||
expect(written).toContain(clearCmd);
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2');
|
||||
@@ -436,7 +459,7 @@ describe('claude-integration-handler', () => {
|
||||
|
||||
const written = mockWriteToPty.mock.calls[0][1] as string;
|
||||
expect(written).toContain(getQuotedCommand(platform, command));
|
||||
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
|
||||
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', command);
|
||||
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3');
|
||||
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
@@ -460,7 +483,7 @@ describe('claude-integration-handler', () => {
|
||||
resumeClaude(terminal, 'abc123', () => null);
|
||||
|
||||
const resumeCall = mockWriteToPty.mock.calls[0][1] as string;
|
||||
expect(resumeCall).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
|
||||
expectPathPrefix(resumeCall, platform, '/opt/claude/bin:/usr/bin', '/opt/claude/bin/claude');
|
||||
expect(resumeCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue');
|
||||
expect(resumeCall).not.toContain('--resume');
|
||||
// sessionId is cleared because --continue doesn't track specific sessions
|
||||
@@ -656,7 +679,7 @@ describe('invokeClaudeAsync', () => {
|
||||
|
||||
const written = mockWriteToPty.mock.calls[0][1] as string;
|
||||
expect(written).toContain(buildCdCommand('/tmp/project'));
|
||||
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
|
||||
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', '/opt/claude/bin/claude');
|
||||
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1');
|
||||
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
|
||||
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default');
|
||||
@@ -914,7 +937,8 @@ describe('claude-integration-handler - Helper Functions', () => {
|
||||
// Use a default terminal name pattern so renaming logic kicks in
|
||||
const terminal = createMockTerminal({ title: 'Terminal 1' });
|
||||
const mockWindow = {
|
||||
webContents: { send: vi.fn() }
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn(), isDestroyed: () => false }
|
||||
};
|
||||
|
||||
finalizeClaudeInvoke(
|
||||
@@ -934,7 +958,8 @@ describe('claude-integration-handler - Helper Functions', () => {
|
||||
// Use a default terminal name pattern so renaming logic kicks in
|
||||
const terminal = createMockTerminal({ title: 'Terminal 2' });
|
||||
const mockWindow = {
|
||||
webContents: { send: vi.fn() }
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn(), isDestroyed: () => false }
|
||||
};
|
||||
|
||||
finalizeClaudeInvoke(
|
||||
@@ -955,7 +980,8 @@ describe('claude-integration-handler - Helper Functions', () => {
|
||||
const terminal = createMockTerminal({ title: 'Terminal 3' });
|
||||
const mockSend = vi.fn();
|
||||
const mockWindow = {
|
||||
webContents: { send: mockSend }
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend, isDestroyed: () => false }
|
||||
};
|
||||
|
||||
finalizeClaudeInvoke(
|
||||
@@ -980,7 +1006,8 @@ describe('claude-integration-handler - Helper Functions', () => {
|
||||
const terminal = createMockTerminal({ title: 'Claude' });
|
||||
const mockSend = vi.fn();
|
||||
const mockWindow = {
|
||||
webContents: { send: mockSend }
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend, isDestroyed: () => false }
|
||||
};
|
||||
|
||||
finalizeClaudeInvoke(
|
||||
@@ -1004,7 +1031,8 @@ describe('claude-integration-handler - Helper Functions', () => {
|
||||
const terminal = createMockTerminal({ title: 'My Custom Terminal' });
|
||||
const mockSend = vi.fn();
|
||||
const mockWindow = {
|
||||
webContents: { send: mockSend }
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend, isDestroyed: () => false }
|
||||
};
|
||||
|
||||
finalizeClaudeInvoke(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as SessionHandler from './session-handler';
|
||||
import * as PtyManager from './pty-manager';
|
||||
import { safeSendToRenderer } from '../ipc-handlers/utils';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape';
|
||||
import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils';
|
||||
@@ -116,6 +117,19 @@ function normalizePathForBash(envPath: string): string {
|
||||
return isWindows() ? envPath.replace(/;/g, ':') : envPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a command already resolves via an absolute executable path.
|
||||
*
|
||||
* When true, we should avoid prefixing PATH=... into the typed shell command because:
|
||||
* 1) PATH is not needed to locate the executable
|
||||
* 2) very long PATH prefixes create huge echoed command lines that can stress terminal rendering
|
||||
*/
|
||||
function isAbsoluteExecutableCommand(command: string): boolean {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
return path.isAbsolute(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate temp file content for OAuth token based on platform
|
||||
*
|
||||
@@ -380,11 +394,8 @@ export function finalizeClaudeInvoke(
|
||||
: 'Claude';
|
||||
terminal.title = title;
|
||||
|
||||
// Notify renderer of title change
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
|
||||
}
|
||||
// Notify renderer of title change (use safeSendToRenderer to prevent SIGABRT on disposed frame)
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
|
||||
}
|
||||
|
||||
// Persist session if project path is available
|
||||
@@ -434,18 +445,15 @@ export function handleRateLimit(
|
||||
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, {
|
||||
terminalId: terminal.id,
|
||||
resetTime,
|
||||
detectedAt: new Date().toISOString(),
|
||||
profileId: currentProfileId,
|
||||
suggestedProfileId: bestProfile?.id,
|
||||
suggestedProfileName: bestProfile?.name,
|
||||
autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit
|
||||
} as RateLimitEvent);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_RATE_LIMIT, {
|
||||
terminalId: terminal.id,
|
||||
resetTime,
|
||||
detectedAt: new Date().toISOString(),
|
||||
profileId: currentProfileId,
|
||||
suggestedProfileId: bestProfile?.id,
|
||||
suggestedProfileName: bestProfile?.name,
|
||||
autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit
|
||||
} as RateLimitEvent);
|
||||
|
||||
if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) {
|
||||
console.warn('[ClaudeIntegration] Auto-switching to profile:', bestProfile.name);
|
||||
@@ -535,19 +543,16 @@ export function handleOAuthToken(
|
||||
// Set flag to watch for Claude's ready state (onboarding complete)
|
||||
terminal.awaitingOnboardingComplete = true;
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
// needsOnboarding: true tells the UI to show "complete setup" message
|
||||
// instead of "success" - user should finish Claude's onboarding before closing
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email: emailFromOutput || keychainCreds.email || profile?.email,
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
// needsOnboarding: true tells the UI to show "complete setup" message
|
||||
// instead of "success" - user should finish Claude's onboarding before closing
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email: emailFromOutput || keychainCreds.email || profile?.email,
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
} else {
|
||||
// Token not in Keychain yet, but profile may still be authenticated via configDir
|
||||
// Check if profile has valid auth (credentials exist in configDir)
|
||||
@@ -559,19 +564,16 @@ export function handleOAuthToken(
|
||||
// Set flag to watch for Claude's ready state (onboarding complete)
|
||||
terminal.awaitingOnboardingComplete = true;
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
// needsOnboarding: true tells the UI to show "complete setup" message
|
||||
// instead of "success" - user should finish Claude's onboarding before closing
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email: emailFromOutput || profile?.email,
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
// needsOnboarding: true tells the UI to show "complete setup" message
|
||||
// instead of "success" - user should finish Claude's onboarding before closing
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email: emailFromOutput || profile?.email,
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration] Login successful but Keychain token not found and no credentials in configDir - user may need to complete authentication manually');
|
||||
}
|
||||
@@ -622,16 +624,13 @@ export function handleOAuthToken(
|
||||
clearKeychainCache(profile.configDir);
|
||||
console.warn('[ClaudeIntegration] Profile credentials verified (not caching token):', profileId);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
} else {
|
||||
console.error('[ClaudeIntegration] Profile not found for OAuth token:', profileId);
|
||||
}
|
||||
@@ -645,17 +644,14 @@ export function handleOAuthToken(
|
||||
// Defensive null check for active profile
|
||||
if (!activeProfile) {
|
||||
console.error('[ClaudeIntegration] Failed to update profile: no active profile found');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: undefined,
|
||||
email,
|
||||
success: false,
|
||||
message: 'No active profile found',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: undefined,
|
||||
email,
|
||||
success: false,
|
||||
message: 'No active profile found',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -686,16 +682,13 @@ export function handleOAuthToken(
|
||||
clearKeychainCache(activeProfile.configDir);
|
||||
console.warn('[ClaudeIntegration] Active profile credentials verified (not caching token):', activeProfile.name);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile.id,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile.id,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,14 +778,11 @@ export function handleOnboardingComplete(
|
||||
}
|
||||
}
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OnboardingCompleteEvent);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OnboardingCompleteEvent);
|
||||
|
||||
// Trigger immediate usage fetch after successful re-authentication
|
||||
// This gives the user immediate feedback that their account is working
|
||||
@@ -845,10 +835,7 @@ export function handleClaudeSessionId(
|
||||
SessionHandler.updateClaudeSessionId(terminal.projectPath, terminal.id, sessionId);
|
||||
}
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -879,10 +866,7 @@ export function handleClaudeExit(
|
||||
}
|
||||
|
||||
// Notify renderer to update UI
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1109,7 +1093,9 @@ export function invokeClaude(
|
||||
const cwdCommand = buildCdCommand(cwd, terminal.shellType);
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
|
||||
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
|
||||
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
|
||||
? ''
|
||||
: buildPathPrefix(claudeEnv.PATH || '');
|
||||
const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId);
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', {
|
||||
@@ -1196,7 +1182,9 @@ export function resumeClaude(
|
||||
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
|
||||
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
|
||||
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
|
||||
? ''
|
||||
: buildPathPrefix(claudeEnv.PATH || '');
|
||||
|
||||
// Always use --continue which resumes the most recent session in the current directory.
|
||||
// This is more reliable than --resume with session IDs since Auto Claude already restores
|
||||
@@ -1220,10 +1208,7 @@ export function resumeClaude(
|
||||
// This preserves user-customized names and prevents renaming on every resume
|
||||
if (shouldAutoRenameTerminal(terminal.title)) {
|
||||
terminal.title = 'Claude';
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
|
||||
}
|
||||
|
||||
// Persist session
|
||||
@@ -1313,7 +1298,9 @@ export async function invokeClaudeAsync(
|
||||
});
|
||||
|
||||
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
|
||||
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
|
||||
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
|
||||
? ''
|
||||
: buildPathPrefix(claudeEnv.PATH || '');
|
||||
const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId);
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', {
|
||||
@@ -1409,7 +1396,9 @@ export async function resumeClaudeAsync(
|
||||
});
|
||||
|
||||
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
|
||||
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
|
||||
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
|
||||
? ''
|
||||
: buildPathPrefix(claudeEnv.PATH || '');
|
||||
|
||||
// Always use --continue which resumes the most recent session in the current directory.
|
||||
// This is more reliable than --resume with session IDs since Auto Claude already restores
|
||||
@@ -1433,10 +1422,7 @@ export async function resumeClaudeAsync(
|
||||
// This preserves user-customized names and prevents renaming on every resume
|
||||
if (shouldAutoRenameTerminal(terminal.title)) {
|
||||
terminal.title = 'Claude';
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
|
||||
}
|
||||
|
||||
// Persist session (async, fire-and-forget to prevent main process blocking)
|
||||
|
||||
@@ -209,18 +209,25 @@ export function setupPtyHandlers(
|
||||
onExitCallback: (terminal: TerminalProcess) => void
|
||||
): void {
|
||||
const { id, pty: ptyProcess } = terminal;
|
||||
terminal.hasExited = false;
|
||||
|
||||
// Handle data from terminal
|
||||
ptyProcess.onData((data) => {
|
||||
// Shutdown guard (GitHub #1469): skip processing to avoid accessing
|
||||
// destroyed BrowserWindow.webContents, which triggers pty.node SIGABRT
|
||||
if (isShuttingDown) return;
|
||||
if (terminal.hasExited) return;
|
||||
|
||||
// Append to output buffer (limit to 100KB)
|
||||
terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000);
|
||||
|
||||
// Call custom data handler
|
||||
onDataCallback(terminal, data);
|
||||
// Call custom data handler. This must never crash the main process:
|
||||
// parser logic in higher layers can throw on unexpected output.
|
||||
try {
|
||||
onDataCallback(terminal, data);
|
||||
} catch (error) {
|
||||
debugError('[PtyManager] onData callback failed for terminal:', id, 'error:', error);
|
||||
}
|
||||
|
||||
// Send to renderer with isDestroyed() check to prevent crashes
|
||||
// when the window is closed during terminal activity
|
||||
@@ -229,6 +236,9 @@ export function setupPtyHandlers(
|
||||
|
||||
// Handle terminal exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
terminal.hasExited = true;
|
||||
// Drop any queued writes for this terminal to avoid writing to dead PTYs.
|
||||
pendingWrites.delete(id);
|
||||
debugLog('[PtyManager] Terminal exited:', id, 'code:', exitCode);
|
||||
|
||||
// Always resolve pending exit promises, even during shutdown
|
||||
@@ -248,8 +258,13 @@ export function setupPtyHandlers(
|
||||
// when the window is closed during terminal exit
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_EXIT, id, exitCode);
|
||||
|
||||
// Call custom exit handler
|
||||
onExitCallback(terminal);
|
||||
// Call custom exit handler. Guard against unexpected exceptions so PTY exit
|
||||
// handling remains robust and doesn't take down the main process.
|
||||
try {
|
||||
onExitCallback(terminal);
|
||||
} catch (error) {
|
||||
debugError('[PtyManager] onExit callback failed for terminal:', id, 'error:', error);
|
||||
}
|
||||
|
||||
// Only delete if this is the SAME terminal object (not a newly created one with same ID).
|
||||
// This prevents a race where destroyTerminal() awaits PTY exit, a new terminal is created
|
||||
@@ -262,11 +277,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.
|
||||
@@ -280,6 +300,11 @@ const pendingWrites = new Map<string, Promise<void>>();
|
||||
*/
|
||||
function performWrite(terminal: TerminalProcess, data: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (terminal.hasExited) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// For large commands, write in chunks to prevent blocking
|
||||
if (data.length > CHUNKED_WRITE_THRESHOLD) {
|
||||
debugLog('[PtyManager:writeToPty] Large write detected, using chunked write');
|
||||
@@ -288,7 +313,7 @@ function performWrite(terminal: TerminalProcess, data: string): Promise<void> {
|
||||
|
||||
const writeChunk = () => {
|
||||
// Check if terminal is still valid before writing
|
||||
if (!terminal.pty) {
|
||||
if (!terminal.pty || terminal.hasExited) {
|
||||
debugError('[PtyManager:writeToPty] Terminal PTY no longer valid, aborting chunked write');
|
||||
resolve();
|
||||
return;
|
||||
@@ -334,6 +359,10 @@ function performWrite(terminal: TerminalProcess, data: string): Promise<void> {
|
||||
*/
|
||||
export function writeToPty(terminal: TerminalProcess, data: string): void {
|
||||
debugLog('[PtyManager:writeToPty] About to write to pty, data length:', data.length);
|
||||
if (terminal.hasExited) {
|
||||
debugError('[PtyManager:writeToPty] Skipping write to exited terminal:', terminal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the previous write Promise for this terminal (if any)
|
||||
const previousWrite = pendingWrites.get(terminal.id) || Promise.resolve();
|
||||
@@ -361,6 +390,11 @@ export function writeToPty(terminal: TerminalProcess, data: string): void {
|
||||
* @returns true if resize was successful, false otherwise
|
||||
*/
|
||||
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): boolean {
|
||||
if (terminal.hasExited) {
|
||||
debugError('[PtyManager] Resize skipped for exited terminal:', terminal.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate dimensions
|
||||
if (cols <= 0 || rows <= 0 || !Number.isFinite(cols) || !Number.isFinite(rows)) {
|
||||
debugError('[PtyManager] Invalid resize dimensions - terminal:', terminal.id, 'cols:', cols, 'rows:', rows);
|
||||
@@ -389,6 +423,10 @@ export function resizePty(terminal: TerminalProcess, cols: number, rows: number)
|
||||
export function killPty(terminal: TerminalProcess, waitForExit: true): Promise<void>;
|
||||
export function killPty(terminal: TerminalProcess, waitForExit?: false): void;
|
||||
export function killPty(terminal: TerminalProcess, waitForExit?: boolean): Promise<void> | void {
|
||||
if (terminal.hasExited) {
|
||||
return waitForExit ? Promise.resolve() : undefined;
|
||||
}
|
||||
|
||||
if (waitForExit) {
|
||||
const exitPromise = waitForPtyExit(terminal.id);
|
||||
try {
|
||||
|
||||
@@ -225,6 +225,18 @@ export function persistAllSessions(terminals: Map<string, TerminalProcess>): voi
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a terminal ID from pendingDelete, allowing session saves to proceed.
|
||||
*
|
||||
* Must be called when re-creating a terminal with a previously-used ID
|
||||
* (e.g., worktree switching, terminal restart after shell exit). Without this,
|
||||
* the pendingDelete guard blocks persistence for the new terminal.
|
||||
*/
|
||||
export function clearPendingDelete(terminalId: string): void {
|
||||
const store = getTerminalSessionStore();
|
||||
store.clearPendingDelete(terminalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session from persistent storage
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as OutputParser from './output-parser';
|
||||
import * as ClaudeIntegration from './claude-integration-handler';
|
||||
import type { TerminalProcess, WindowGetter } from './types';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { safeSendToRenderer } from '../ipc-handlers/utils';
|
||||
|
||||
/**
|
||||
* Event handler callbacks
|
||||
@@ -109,10 +110,7 @@ export function createEventCallbacks(
|
||||
ClaudeIntegration.handleOnboardingComplete(terminal, data, getWindow);
|
||||
},
|
||||
onClaudeBusyChange: (terminal, isBusy) => {
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy);
|
||||
}
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy);
|
||||
},
|
||||
onClaudeExit: (terminal) => {
|
||||
ClaudeIntegration.handleClaudeExit(terminal, getWindow);
|
||||
|
||||
@@ -54,6 +54,13 @@ export async function createTerminal(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Clear any pendingDelete for this terminal ID. This handles the case where
|
||||
// a terminal is destroyed and immediately re-created with the same ID (e.g.,
|
||||
// worktree switching, terminal restart after shell exit). Without this, the
|
||||
// pendingDelete guard (5-second window) blocks session persistence for the
|
||||
// new terminal, causing it to be invisible to the session store.
|
||||
SessionHandler.clearPendingDelete(id);
|
||||
|
||||
try {
|
||||
// For auth terminals, don't inject existing OAuth token - we want a fresh login
|
||||
const profileEnv = skipOAuthToken ? {} : PtyManager.getActiveProfileEnv();
|
||||
@@ -101,6 +108,7 @@ export async function createTerminal(
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
isClaudeMode: false,
|
||||
hasExited: false,
|
||||
projectPath,
|
||||
cwd: terminalCwd,
|
||||
outputBuffer: '',
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface TerminalProcess {
|
||||
shellType?: WindowsShellType;
|
||||
/** Whether this terminal is waiting for Claude onboarding to complete (login flow) */
|
||||
awaitingOnboardingComplete?: boolean;
|
||||
/** Whether PTY has emitted exit; used to avoid writes/resizes on dead PTYs */
|
||||
hasExited?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -174,6 +174,8 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
|
||||
onSelectIssue={selectIssue}
|
||||
onInvestigate={handleInvestigate}
|
||||
onLoadMore={!isSearchActive ? handleLoadMore : undefined}
|
||||
onRetry={handleRefresh}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Key,
|
||||
Shield,
|
||||
WifiOff,
|
||||
SearchX,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { parseGitHubError } from '../utils/github-error-parser';
|
||||
import type { GitHubErrorInfo, GitHubErrorType } from '../types';
|
||||
|
||||
/**
|
||||
* Props for the GitHubErrorDisplay component.
|
||||
*/
|
||||
export interface GitHubErrorDisplayProps {
|
||||
/** Raw error string or pre-parsed GitHubErrorInfo */
|
||||
error: string | GitHubErrorInfo | null;
|
||||
/** Callback when user clicks retry button */
|
||||
onRetry?: () => void;
|
||||
/** Callback when user clicks settings button */
|
||||
onOpenSettings?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show as compact inline error (vs full-width card) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for each error type: icon, color, title key.
|
||||
*/
|
||||
const ERROR_CONFIG: Record<
|
||||
GitHubErrorType,
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
titleKey: string;
|
||||
iconColorClass: string;
|
||||
}
|
||||
> = {
|
||||
rate_limit: {
|
||||
icon: Clock,
|
||||
titleKey: 'githubErrors.rateLimitTitle',
|
||||
iconColorClass: 'text-warning',
|
||||
},
|
||||
auth: {
|
||||
icon: Key,
|
||||
titleKey: 'githubErrors.authTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
permission: {
|
||||
icon: Shield,
|
||||
titleKey: 'githubErrors.permissionTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
not_found: {
|
||||
icon: SearchX,
|
||||
titleKey: 'githubErrors.notFoundTitle',
|
||||
iconColorClass: 'text-muted-foreground',
|
||||
},
|
||||
network: {
|
||||
icon: WifiOff,
|
||||
titleKey: 'githubErrors.networkTitle',
|
||||
iconColorClass: 'text-warning',
|
||||
},
|
||||
unknown: {
|
||||
icon: AlertTriangle,
|
||||
titleKey: 'githubErrors.unknownTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Base message keys for each error type.
|
||||
* Hoisted to module scope to avoid recreation on every function call.
|
||||
*/
|
||||
const BASE_MESSAGE_KEYS: Record<GitHubErrorType, string> = {
|
||||
rate_limit: 'githubErrors.rateLimitMessage',
|
||||
auth: 'githubErrors.authMessage',
|
||||
permission: 'githubErrors.permissionMessage',
|
||||
not_found: 'githubErrors.notFoundMessage',
|
||||
network: 'githubErrors.networkMessage',
|
||||
unknown: 'githubErrors.unknownMessage',
|
||||
};
|
||||
|
||||
/**
|
||||
* Countdown time components for i18n-friendly formatting.
|
||||
*/
|
||||
interface CountdownComponents {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate countdown time components from reset time.
|
||||
* Returns numeric values for i18n-friendly formatting in the component.
|
||||
*/
|
||||
function getCountdownComponents(resetTime: Date): CountdownComponents | null {
|
||||
const now = new Date();
|
||||
const diffMs = resetTime.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
return {
|
||||
hours: diffHours,
|
||||
minutes: diffHours > 0 ? diffMins % 60 : diffMins,
|
||||
seconds: diffSecs % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the most specific message key based on available metadata.
|
||||
* Pure function extracted to module scope to avoid recreation on each render.
|
||||
* @param info - The error info object
|
||||
* @param rateLimitDiffMs - Pre-computed time difference in milliseconds (avoids dual calculation)
|
||||
*/
|
||||
function getMessageKey(info: GitHubErrorInfo, rateLimitDiffMs?: number): string {
|
||||
if (info.type === 'rate_limit' && rateLimitDiffMs !== undefined && rateLimitDiffMs > 0) {
|
||||
const diffMins = Math.ceil(rateLimitDiffMs / 60000);
|
||||
return diffMins >= 60
|
||||
? 'githubErrors.rateLimitMessageHours'
|
||||
: 'githubErrors.rateLimitMessageMinutes';
|
||||
}
|
||||
if (info.type === 'permission' && info.requiredScopes && info.requiredScopes.length > 0) {
|
||||
return 'githubErrors.permissionMessageScopes';
|
||||
}
|
||||
return BASE_MESSAGE_KEYS[info.type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays GitHub API errors with appropriate icons,
|
||||
* messages, and action buttons based on error type.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // With raw error string
|
||||
* <GitHubErrorDisplay
|
||||
* error="GitHub API error: 403 - Rate limit exceeded"
|
||||
* onRetry={handleRetry}
|
||||
* />
|
||||
*
|
||||
* // With pre-parsed error info
|
||||
* <GitHubErrorDisplay
|
||||
* error={errorInfo}
|
||||
* onOpenSettings={handleOpenSettings}
|
||||
* compact
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function GitHubErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
onOpenSettings,
|
||||
className,
|
||||
compact = false,
|
||||
}: GitHubErrorDisplayProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Parse error if it's a string, otherwise use the provided GitHubErrorInfo
|
||||
// Memoize to prevent useEffect churn from new Date references on each render
|
||||
const errorInfo: GitHubErrorInfo = useMemo(
|
||||
() =>
|
||||
typeof error === 'string' || error === null
|
||||
? parseGitHubError(error)
|
||||
: error,
|
||||
[error]
|
||||
);
|
||||
|
||||
// State for rate limit countdown components
|
||||
const [countdownComponents, setCountdownComponents] = useState<CountdownComponents | null>(() =>
|
||||
errorInfo.rateLimitResetTime
|
||||
? getCountdownComponents(errorInfo.rateLimitResetTime)
|
||||
: null
|
||||
);
|
||||
|
||||
// Update countdown every second for rate limit errors
|
||||
// Extract timestamp for stable useEffect dependency (avoids optional chaining in deps)
|
||||
const resetTimeMs = errorInfo.rateLimitResetTime?.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
if (errorInfo.type !== 'rate_limit' || !errorInfo.rateLimitResetTime) {
|
||||
// Clear stale countdown state when error type changes away from rate_limit
|
||||
setCountdownComponents(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetTime = errorInfo.rateLimitResetTime;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const updateCountdown = () => {
|
||||
const components = getCountdownComponents(resetTime);
|
||||
setCountdownComponents(components);
|
||||
// Stop the interval when countdown expires
|
||||
if (!components && intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateCountdown();
|
||||
|
||||
// Only set interval if countdown is still active
|
||||
if (getCountdownComponents(resetTime)) {
|
||||
intervalId = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when error changes
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [errorInfo.type, resetTimeMs]);
|
||||
|
||||
// Format countdown using i18n
|
||||
const formatCountdownDisplay = (components: CountdownComponents | null): string => {
|
||||
if (!components) return '';
|
||||
if (components.hours > 0) {
|
||||
return t('githubErrors.countdownHoursMinutes', {
|
||||
hours: components.hours,
|
||||
minutes: components.minutes,
|
||||
});
|
||||
}
|
||||
return t('githubErrors.countdownMinutesSeconds', {
|
||||
minutes: components.minutes,
|
||||
seconds: components.seconds,
|
||||
});
|
||||
};
|
||||
|
||||
// Get configuration for this error type
|
||||
const config = ERROR_CONFIG[errorInfo.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
// Determine which actions to show
|
||||
const showRetry = ['rate_limit', 'network', 'unknown'].includes(errorInfo.type);
|
||||
const showSettings = ['auth', 'permission'].includes(errorInfo.type);
|
||||
const isRateLimitExpired =
|
||||
errorInfo.type === 'rate_limit' &&
|
||||
errorInfo.rateLimitResetTime &&
|
||||
new Date() >= errorInfo.rateLimitResetTime;
|
||||
|
||||
// Don't render if no error
|
||||
if (!error) return null;
|
||||
|
||||
// Compute time remaining once for both message key selection and translation
|
||||
const rateLimitDiffMs = errorInfo.rateLimitResetTime
|
||||
? errorInfo.rateLimitResetTime.getTime() - Date.now()
|
||||
: undefined;
|
||||
|
||||
// Get the translated message with appropriate interpolation values
|
||||
const messageKey = getMessageKey(errorInfo, rateLimitDiffMs);
|
||||
// Only pass positive minutes/hours values to avoid stale negative/zero values
|
||||
const rawMinutes = rateLimitDiffMs ? Math.ceil(rateLimitDiffMs / 60000) : undefined;
|
||||
const minutes = rawMinutes && rawMinutes > 0 ? rawMinutes : undefined;
|
||||
const hours = minutes ? Math.ceil(minutes / 60) : undefined;
|
||||
|
||||
const errorMessage = t(messageKey, {
|
||||
defaultValue: errorInfo.message,
|
||||
minutes,
|
||||
hours,
|
||||
scopes: errorInfo.requiredScopes?.join(', '),
|
||||
});
|
||||
|
||||
// Compact variant for inline display
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={errorMessage}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-lg bg-muted/50 border border-border',
|
||||
className
|
||||
)}
|
||||
title={errorMessage}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4 shrink-0', config.iconColorClass)} />
|
||||
<span className="text-sm text-muted-foreground flex-1 truncate">
|
||||
{t(config.titleKey)}
|
||||
</span>
|
||||
{showRetry && onRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t('buttons.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && onOpenSettings && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenSettings}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Settings2 className="h-3 w-3 mr-1" />
|
||||
{t('actions.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full card variant for blocking errors
|
||||
return (
|
||||
<Card role="alert" className={cn('border-destructive/50 m-4', className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<Icon className={cn('h-6 w-6', config.iconColorClass)} />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-md">
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
{t(config.titleKey)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
{/* Rate limit countdown display */}
|
||||
{errorInfo.type === 'rate_limit' && countdownComponents && (
|
||||
<p className="text-xs text-warning font-medium">
|
||||
{t('githubErrors.resetsIn', { time: formatCountdownDisplay(countdownComponents) })}
|
||||
</p>
|
||||
)}
|
||||
{/* Rate limit expired - show retry prompt */}
|
||||
{isRateLimitExpired && (
|
||||
<p className="text-xs text-primary">
|
||||
{t('githubErrors.rateLimitExpired')}
|
||||
</p>
|
||||
)}
|
||||
{/* Required scopes for permission errors */}
|
||||
{errorInfo.requiredScopes && errorInfo.requiredScopes.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('githubErrors.requiredScopes')}:{' '}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
{errorInfo.requiredScopes.join(', ')}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
{showRetry && onRetry && (
|
||||
<Button onClick={onRetry} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('buttons.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && onOpenSettings && (
|
||||
<Button onClick={onOpenSettings} variant="outline" size="sm">
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
{t('actions.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { IssueListItem } from './IssueListItem';
|
||||
import { EmptyState } from './EmptyStates';
|
||||
import { GitHubErrorDisplay } from './GitHubErrorDisplay';
|
||||
import type { IssueListProps } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,7 +16,9 @@ export function IssueList({
|
||||
error,
|
||||
onSelectIssue,
|
||||
onInvestigate,
|
||||
onLoadMore
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
onOpenSettings
|
||||
}: IssueListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -50,12 +53,12 @@ export function IssueList({
|
||||
// Load-more errors are shown inline near the load-more trigger
|
||||
if (error && issues.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-destructive/10 border-b border-destructive/30">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
<GitHubErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
className="flex-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,15 +88,18 @@ export function IssueList({
|
||||
))}
|
||||
|
||||
{/* Load more trigger / Loading indicator */}
|
||||
{/* Inline error for load-more failures (visible even when onLoadMore is undefined during search) */}
|
||||
{error && issues.length > 0 && (
|
||||
<GitHubErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
compact
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
{onLoadMore && (
|
||||
<div ref={loadMoreTriggerRef} className="py-4 flex flex-col items-center gap-2">
|
||||
{/* Inline error for load-more failures (when issues are already loaded) */}
|
||||
{error && issues.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/**
|
||||
* Unit tests for GitHubErrorDisplay component.
|
||||
* Tests error display, icon rendering, button visibility, and countdown functionality.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { GitHubErrorDisplay } from '../GitHubErrorDisplay';
|
||||
import type { GitHubErrorInfo } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'githubErrors.rateLimitTitle': 'GitHub Rate Limit Reached',
|
||||
'githubErrors.authTitle': 'GitHub Authentication Required',
|
||||
'githubErrors.permissionTitle': 'GitHub Permission Denied',
|
||||
'githubErrors.notFoundTitle': 'GitHub Resource Not Found',
|
||||
'githubErrors.networkTitle': 'GitHub Connection Error',
|
||||
'githubErrors.unknownTitle': 'GitHub Error',
|
||||
'githubErrors.rateLimitMessage': 'GitHub API rate limit reached. Please wait a moment before trying again.',
|
||||
'githubErrors.rateLimitMessageMinutes': `GitHub API rate limit reached. Please wait ${options?.minutes ?? 'X'} minute(s) before trying again.`,
|
||||
'githubErrors.rateLimitMessageHours': `GitHub API rate limit reached. Rate limit resets in approximately ${options?.hours ?? 'X'} hour(s).`,
|
||||
'githubErrors.authMessage': 'GitHub authentication failed. Please check your GitHub token in Settings.',
|
||||
'githubErrors.permissionMessage': 'GitHub permission denied. Your token may not have the required access.',
|
||||
'githubErrors.permissionMessageScopes': `GitHub permission denied. Your token is missing required scopes: ${options?.scopes ?? ''}. Please update your GitHub token in Settings.`,
|
||||
'githubErrors.notFoundMessage': 'The requested GitHub resource was not found.',
|
||||
'githubErrors.networkMessage': 'Unable to connect to GitHub. Please check your internet connection.',
|
||||
'githubErrors.unknownMessage': 'An unexpected error occurred while communicating with GitHub.',
|
||||
'githubErrors.resetsIn': options?.time ? `Resets in ${options.time as string}` : 'Resets in',
|
||||
'githubErrors.countdownHoursMinutes': `${options?.hours ?? 0}h ${options?.minutes ?? 0}m`,
|
||||
'githubErrors.countdownMinutesSeconds': `${options?.minutes ?? 0}m ${options?.seconds ?? 0}s`,
|
||||
'githubErrors.rateLimitExpired': 'Rate limit has reset. You can retry now.',
|
||||
'githubErrors.requiredScopes': 'Required scopes',
|
||||
'buttons.retry': 'Retry',
|
||||
'actions.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Helper to create mock GitHubErrorInfo
|
||||
function createMockErrorInfo(
|
||||
type: GitHubErrorInfo['type'],
|
||||
overrides: Partial<GitHubErrorInfo> = {}
|
||||
): GitHubErrorInfo {
|
||||
const defaults: Record<string, GitHubErrorInfo> = {
|
||||
rate_limit: {
|
||||
type: 'rate_limit',
|
||||
message: 'GitHub API rate limit reached. Please wait a moment before trying again.',
|
||||
statusCode: 403,
|
||||
},
|
||||
auth: {
|
||||
type: 'auth',
|
||||
message: 'GitHub authentication failed. Please check your GitHub token in Settings.',
|
||||
statusCode: 401,
|
||||
},
|
||||
permission: {
|
||||
type: 'permission',
|
||||
message: 'GitHub permission denied. Your token may not have the required access.',
|
||||
statusCode: 403,
|
||||
},
|
||||
not_found: {
|
||||
type: 'not_found',
|
||||
message: 'The requested GitHub resource was not found.',
|
||||
statusCode: 404,
|
||||
},
|
||||
network: {
|
||||
type: 'network',
|
||||
message: 'Unable to connect to GitHub. Please check your internet connection.',
|
||||
},
|
||||
unknown: {
|
||||
type: 'unknown',
|
||||
message: 'An unexpected error occurred while communicating with GitHub.',
|
||||
},
|
||||
};
|
||||
|
||||
return { ...defaults[type], ...overrides };
|
||||
}
|
||||
|
||||
describe('GitHubErrorDisplay', () => {
|
||||
describe('rendering null/empty states', () => {
|
||||
it('should render nothing when error is null', () => {
|
||||
const { container } = render(<GitHubErrorDisplay error={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when error is an empty string', () => {
|
||||
// Empty string is falsy, so component should return null
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={'' as string} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with string error', () => {
|
||||
it('should render error display when error is a string', () => {
|
||||
render(<GitHubErrorDisplay error="401 Unauthorized" />);
|
||||
|
||||
// Should show the auth title (parsed from the error)
|
||||
expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error display for rate limit string error', () => {
|
||||
render(<GitHubErrorDisplay error="rate limit exceeded" />);
|
||||
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with GitHubErrorInfo object', () => {
|
||||
it('should render rate_limit error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/GitHub API rate limit reached/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render auth error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument();
|
||||
expect(screen.getByText(/authentication failed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render permission error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: ['repo', 'workflow'],
|
||||
});
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Permission Denied')).toBeInTheDocument();
|
||||
// Check that permission message is rendered
|
||||
expect(screen.getByText(/Your token is missing required scopes/)).toBeInTheDocument();
|
||||
// Should show required scopes in the code element
|
||||
expect(screen.getByText('repo, workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render not_found error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('not_found');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Resource Not Found')).toBeInTheDocument();
|
||||
expect(screen.getByText(/not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render network error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Connection Error')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unable to connect/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render unknown error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('unknown');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Error')).toBeInTheDocument();
|
||||
expect(screen.getByText(/unexpected error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact mode', () => {
|
||||
it('should render compact variant when compact=true', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact />);
|
||||
|
||||
// In compact mode, the title is in a smaller span
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
// Should not render the card structure (no centered layout)
|
||||
expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button in compact mode for rate_limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show settings button in compact mode for auth errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('full card mode (default)', () => {
|
||||
it('should render card structure by default', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Should render heading
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button for rate_limit errors with onRetry callback', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show retry button for network errors', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button for unknown errors', () => {
|
||||
const errorInfo = createMockErrorInfo('unknown');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for auth errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for not_found errors', () => {
|
||||
const errorInfo = createMockErrorInfo('not_found');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show settings button for auth errors with onOpenSettings callback', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show settings button for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show settings button for rate_limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show settings button for network errors', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limit countdown', () => {
|
||||
it('should display countdown for rate limit errors with reset time', () => {
|
||||
// Set reset time 5 minutes in the future
|
||||
const resetTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Should show countdown in "Xm Ys" format (e.g., "4m 59s" or "5m 0s")
|
||||
expect(screen.getByText(/Resets in \d+m \d+s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should set up interval to update countdown', () => {
|
||||
vi.useFakeTimers();
|
||||
const resetTime = new Date(Date.now() + 2 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Initial countdown should be displayed
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
// Verify interval is running by checking timers
|
||||
const timerCount = vi.getTimerCount();
|
||||
expect(timerCount).toBe(1); // One interval should be running
|
||||
|
||||
// Advance time and verify interval still fires
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should NOT show countdown for non-rate-limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show rate limit expired message when reset time has passed', () => {
|
||||
// Set reset time in the past
|
||||
const resetTime = new Date(Date.now() - 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Rate limit has reset. You can retry now.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cleanup interval on unmount', () => {
|
||||
vi.useFakeTimers();
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
|
||||
const resetTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
const { unmount } = render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Verify the countdown was rendered
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
// Unmount and verify clearInterval was called
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('required scopes display', () => {
|
||||
it('should display required scopes for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: ['repo', 'read:org', 'workflow'],
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('Required scopes:')).toBeInTheDocument();
|
||||
// The scopes appear in a code element
|
||||
expect(screen.getByText('repo, read:org, workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display scopes section when no scopes are provided', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: undefined,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display scopes section when scopes array is empty', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: [],
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should apply custom className in full card mode', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={errorInfo} className="custom-class" />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should apply custom className in compact mode', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={errorInfo} compact className="custom-compact-class" />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-compact-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback stability', () => {
|
||||
it('should not call onRetry on initial render', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onOpenSettings on initial render', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(onOpenSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have role="alert" for screen reader announcements', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// The error card should have role="alert" for accessibility
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have role="alert" in compact mode', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact />);
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible button labels', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={() => { /* no-op */ }} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /retry/i });
|
||||
expect(button).toHaveTextContent('Retry');
|
||||
});
|
||||
|
||||
it('should have accessible settings button label', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={() => { /* no-op */ }} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /settings/i });
|
||||
expect(button).toHaveTextContent('Settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,3 +6,4 @@ export { IssueListHeader } from './IssueListHeader';
|
||||
export { IssueList } from './IssueList';
|
||||
export { AutoFixButton } from './AutoFixButton';
|
||||
export { BatchReviewWizard } from './BatchReviewWizard';
|
||||
export { GitHubErrorDisplay } from './GitHubErrorDisplay';
|
||||
|
||||
@@ -3,6 +3,47 @@ import type { AutoFixConfig, AutoFixQueueItem } from '../../../../preload/api/mo
|
||||
|
||||
export type FilterState = 'open' | 'closed' | 'all';
|
||||
|
||||
/**
|
||||
* Classification types for GitHub API errors.
|
||||
* Used to determine appropriate icon, message, and actions for error display.
|
||||
*/
|
||||
export type GitHubErrorType =
|
||||
| 'rate_limit'
|
||||
| 'auth'
|
||||
| 'permission'
|
||||
| 'network'
|
||||
| 'not_found'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Parsed GitHub error information with metadata.
|
||||
* Returned by the github-error-parser utility.
|
||||
*
|
||||
* IMPORTANT: The `message` field contains hardcoded English strings intended
|
||||
* ONLY as a fallback defaultValue for i18n translation. Direct consumers should
|
||||
* use the `type` field to look up the appropriate translation key (e.g.,
|
||||
* 'githubErrors.rateLimitMessage') via react-i18next rather than displaying
|
||||
* `message` directly. This ensures proper localization for all users.
|
||||
*/
|
||||
export interface GitHubErrorInfo {
|
||||
/** The classified error type */
|
||||
type: GitHubErrorType;
|
||||
/**
|
||||
* User-friendly error message in English.
|
||||
* NOTE: Use only as defaultValue for i18n - do not display directly.
|
||||
* Use type field to look up translation key (e.g., 'githubErrors.rateLimitMessage').
|
||||
*/
|
||||
message: string;
|
||||
/** Original raw error string (for debugging/details) */
|
||||
rawMessage?: string;
|
||||
/** Rate limit reset time (only for rate_limit type) */
|
||||
rateLimitResetTime?: Date;
|
||||
/** Required OAuth scopes that are missing (only for permission type) */
|
||||
requiredScopes?: string[];
|
||||
/** HTTP status code if available */
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export interface GitHubIssuesProps {
|
||||
onOpenSettings?: () => void;
|
||||
/** Navigate to view a task in the kanban board */
|
||||
@@ -76,6 +117,10 @@ export interface IssueListProps {
|
||||
onSelectIssue: (issueNumber: number) => void;
|
||||
onInvestigate: (issue: GitHubIssue) => void;
|
||||
onLoadMore?: () => void;
|
||||
/** Callback for retry button in error display */
|
||||
onRetry?: () => void;
|
||||
/** Callback for settings button in error display */
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
|
||||
+691
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* Unit tests for GitHub API error parser utility.
|
||||
* Tests error classification, metadata extraction, and helper functions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseGitHubError,
|
||||
isRateLimitError,
|
||||
isAuthError,
|
||||
isNetworkError,
|
||||
isRecoverableError,
|
||||
requiresSettingsAction,
|
||||
} from '../github-error-parser';
|
||||
import type { GitHubErrorType } from '../../types';
|
||||
|
||||
describe('parseGitHubError', () => {
|
||||
describe('null/undefined/empty handling', () => {
|
||||
it('should return unknown for null input', () => {
|
||||
const result = parseGitHubError(null);
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for undefined input', () => {
|
||||
const result = parseGitHubError(undefined);
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for empty string', () => {
|
||||
const result = parseGitHubError('');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for whitespace-only string', () => {
|
||||
const result = parseGitHubError(' ');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate_limit errors', () => {
|
||||
it('should detect "rate limit exceeded" pattern', () => {
|
||||
const result = parseGitHubError('GitHub API error: rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('rate limit');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "API rate limit exceeded" pattern', () => {
|
||||
const result = parseGitHubError('API rate limit exceeded for user');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "too many requests" pattern', () => {
|
||||
const result = parseGitHubError('Error: too many requests');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "403 rate limit" pattern', () => {
|
||||
const result = parseGitHubError('403 rate limit reached');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "abuse rate limit" pattern', () => {
|
||||
const result = parseGitHubError('Abuse rate limit triggered');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "secondary rate limit" pattern', () => {
|
||||
const result = parseGitHubError('Secondary rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should extract rate limit reset time from ISO date format', () => {
|
||||
const result = parseGitHubError('rate limit exceeded, resets at 2024-01-15T12:00:00Z');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rateLimitResetTime).toBeInstanceOf(Date);
|
||||
expect(result.rateLimitResetTime?.getUTCFullYear()).toBe(2024);
|
||||
});
|
||||
|
||||
it('should extract rate limit reset time from Unix timestamp', () => {
|
||||
const result = parseGitHubError('X-RateLimit-Reset: 1705312800');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rateLimitResetTime).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should generate user-friendly message with time remaining', () => {
|
||||
// Create a date 5 minutes in the future
|
||||
const futureDate = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const isoString = futureDate.toISOString();
|
||||
const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('rate limit');
|
||||
});
|
||||
|
||||
it('should generate fallback message when reset time has passed', () => {
|
||||
// Create a date in the past
|
||||
const pastDate = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const isoString = pastDate.toISOString();
|
||||
const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('moment');
|
||||
});
|
||||
|
||||
it('should include raw message truncated to MAX_RAW_ERROR_LENGTH', () => {
|
||||
const longError = 'rate limit exceeded ' + 'x'.repeat(600);
|
||||
const result = parseGitHubError(longError);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rawMessage).toBeDefined();
|
||||
expect(result.rawMessage?.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth errors', () => {
|
||||
it('should detect "401" pattern', () => {
|
||||
const result = parseGitHubError('HTTP 401 Unauthorized');
|
||||
expect(result.type).toBe('auth');
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should detect "unauthorized" pattern', () => {
|
||||
const result = parseGitHubError('Error: unauthorized access');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "bad credentials" pattern', () => {
|
||||
const result = parseGitHubError('Bad credentials');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "authentication failed" pattern', () => {
|
||||
const result = parseGitHubError('Authentication failed');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "invalid token" pattern', () => {
|
||||
const result = parseGitHubError('Invalid token provided');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "token expired" pattern', () => {
|
||||
const result = parseGitHubError('Token expired');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "not authenticated" pattern', () => {
|
||||
const result = parseGitHubError('Not authenticated');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message mentioning Settings', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.message).toContain('authentication');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('not_found errors', () => {
|
||||
it('should detect "404" pattern', () => {
|
||||
const result = parseGitHubError('HTTP 404 Not Found');
|
||||
expect(result.type).toBe('not_found');
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should detect "not found" pattern', () => {
|
||||
const result = parseGitHubError('Repository not found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "no such repository" pattern', () => {
|
||||
const result = parseGitHubError('No such repository exists');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "does not exist" pattern', () => {
|
||||
const result = parseGitHubError('Resource does not exist');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "user not found" pattern', () => {
|
||||
const result = parseGitHubError('User not found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message about verifying repository', () => {
|
||||
const result = parseGitHubError('404 Not Found');
|
||||
expect(result.message).toContain('not found');
|
||||
expect(result.message).toContain('verify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network errors', () => {
|
||||
it('should detect "network error" pattern', () => {
|
||||
const result = parseGitHubError('Network error');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "failed to fetch" pattern', () => {
|
||||
const result = parseGitHubError('Failed to fetch data');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ECONNREFUSED" pattern', () => {
|
||||
const result = parseGitHubError('Error: ECONNREFUSED');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ECONNRESET" pattern', () => {
|
||||
const result = parseGitHubError('Error: ECONNRESET');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ETIMEDOUT" pattern', () => {
|
||||
const result = parseGitHubError('Error: ETIMEDOUT');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "connection refused" pattern', () => {
|
||||
const result = parseGitHubError('Connection refused');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "connection timeout" pattern', () => {
|
||||
const result = parseGitHubError('Connection timeout');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "DNS error" pattern', () => {
|
||||
const result = parseGitHubError('DNS error occurred');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "offline" pattern', () => {
|
||||
const result = parseGitHubError('You are offline');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "no internet" pattern', () => {
|
||||
const result = parseGitHubError('No internet connection');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message about internet connection', () => {
|
||||
const result = parseGitHubError('Network error');
|
||||
expect(result.message).toContain('internet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission errors', () => {
|
||||
it('should detect "403" pattern (without rate limit context)', () => {
|
||||
const result = parseGitHubError('HTTP 403 Forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "forbidden" pattern', () => {
|
||||
const result = parseGitHubError('Access forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "permission denied" pattern', () => {
|
||||
const result = parseGitHubError('Permission denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "insufficient scope" pattern', () => {
|
||||
const result = parseGitHubError('Insufficient scope');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "access denied" pattern', () => {
|
||||
const result = parseGitHubError('Access denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "repository access denied" pattern', () => {
|
||||
const result = parseGitHubError('Repository access denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "requires admin access" pattern', () => {
|
||||
const result = parseGitHubError('Requires admin access');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "missing required scope" pattern', () => {
|
||||
const result = parseGitHubError('Missing required scope');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should extract required scopes from error message with 403', () => {
|
||||
const result = parseGitHubError('403 Forbidden - missing scopes: repo, read:org');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
expect(result.requiredScopes).toContain('read:org');
|
||||
});
|
||||
|
||||
it('should extract scopes from "requires:" format with 403', () => {
|
||||
const result = parseGitHubError('403 - Requires: repo, workflow');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
expect(result.requiredScopes).toContain('workflow');
|
||||
});
|
||||
|
||||
it('should extract scopes from X-Accepted-OAuth-Scopes header with 403', () => {
|
||||
const result = parseGitHubError('403 Forbidden X-Accepted-OAuth-Scopes: repo');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message with scopes', () => {
|
||||
const result = parseGitHubError('403 Forbidden - missing scopes: repo, workflow');
|
||||
expect(result.message).toContain('repo');
|
||||
expect(result.message).toContain('workflow');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message without scopes', () => {
|
||||
const result = parseGitHubError('403 Forbidden');
|
||||
expect(result.message).toContain('permission');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown errors', () => {
|
||||
it('should return unknown for unrecognized error patterns', () => {
|
||||
const result = parseGitHubError('Something unexpected happened');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include raw message for unknown errors', () => {
|
||||
const result = parseGitHubError('Custom error message');
|
||||
expect(result.rawMessage).toBe('Custom error message');
|
||||
});
|
||||
|
||||
it('should extract status code even for unknown errors', () => {
|
||||
const result = parseGitHubError('HTTP 500 Internal Server Error');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error classification priority', () => {
|
||||
it('should prioritize rate_limit over permission (both 403)', () => {
|
||||
const result = parseGitHubError('403 rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should classify as permission when 403 without rate limit context', () => {
|
||||
const result = parseGitHubError('403 forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should handle errors with multiple patterns correctly', () => {
|
||||
// Rate limit should take priority
|
||||
const result = parseGitHubError('403 API rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should prioritize auth over not_found when both patterns present', () => {
|
||||
// "401" should be classified as auth, not not_found
|
||||
const result = parseGitHubError('HTTP 401 Unauthorized - user not found');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should prioritize auth over network when 401 appears with network context', () => {
|
||||
const result = parseGitHubError('Network error: HTTP 401');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should classify as not_found when 404 without auth patterns', () => {
|
||||
const result = parseGitHubError('HTTP 404 Not Found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should not match bare 401 in unrelated numbers', () => {
|
||||
// The word boundary should prevent matching "1401" as a 401 error
|
||||
const result = parseGitHubError('Error code 14010 occurred');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should not match bare 404 embedded in other numbers', () => {
|
||||
// The word boundary should prevent matching "404" embedded in "14040"
|
||||
const result = parseGitHubError('Error code 14040 occurred');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline error messages', () => {
|
||||
const result = parseGitHubError(`Error occurred:
|
||||
HTTP 401 Unauthorized
|
||||
Please check your credentials`);
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', () => {
|
||||
const testCases = [
|
||||
{ input: 'RATE LIMIT EXCEEDED', expected: 'rate_limit' as GitHubErrorType },
|
||||
{ input: 'UNAUTHORIZED', expected: 'auth' as GitHubErrorType },
|
||||
{ input: 'NOT FOUND', expected: 'not_found' as GitHubErrorType },
|
||||
{ input: 'NETWORK ERROR', expected: 'network' as GitHubErrorType },
|
||||
{ input: 'FORBIDDEN', expected: 'permission' as GitHubErrorType },
|
||||
];
|
||||
|
||||
for (const { input, expected } of testCases) {
|
||||
const result = parseGitHubError(input);
|
||||
expect(result.type).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle errors with JSON content', () => {
|
||||
const result = parseGitHubError('{"message":"Bad credentials","status":401}');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should handle errors with leading/trailing whitespace', () => {
|
||||
const result = parseGitHubError(' 401 Unauthorized ');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should sanitize very long error messages', () => {
|
||||
const longError = 'A'.repeat(1000);
|
||||
const result = parseGitHubError(longError);
|
||||
expect(result.rawMessage?.length).toBeLessThanOrEqual(503);
|
||||
expect(result.rawMessage).toContain('...');
|
||||
});
|
||||
|
||||
it('should not include rateLimitResetTime for non-rate-limit errors', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.rateLimitResetTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include requiredScopes for non-permission errors', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.requiredScopes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it('should return true for rate limit errors', () => {
|
||||
expect(isRateLimitError('rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimitError('API rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimitError('too many requests')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit errors', () => {
|
||||
expect(isRateLimitError('401 Unauthorized')).toBe(false);
|
||||
expect(isRateLimitError('404 Not Found')).toBe(false);
|
||||
expect(isRateLimitError('Network error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isRateLimitError(null)).toBe(false);
|
||||
expect(isRateLimitError(undefined)).toBe(false);
|
||||
expect(isRateLimitError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
expect(isRateLimitError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isRateLimitError(null, parsedInfo)).toBe(true);
|
||||
expect(isRateLimitError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isRateLimitError('rate limit exceeded', authParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthError', () => {
|
||||
it('should return true for auth errors', () => {
|
||||
expect(isAuthError('401 Unauthorized')).toBe(true);
|
||||
expect(isAuthError('Bad credentials')).toBe(true);
|
||||
expect(isAuthError('Invalid token')).toBe(true);
|
||||
expect(isAuthError('Not authenticated')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-auth errors', () => {
|
||||
expect(isAuthError('rate limit exceeded')).toBe(false);
|
||||
expect(isAuthError('404 Not Found')).toBe(false);
|
||||
expect(isAuthError('Network error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isAuthError(null)).toBe(false);
|
||||
expect(isAuthError(undefined)).toBe(false);
|
||||
expect(isAuthError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isAuthError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isAuthError(null, parsedInfo)).toBe(true);
|
||||
expect(isAuthError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const rateLimitParsedInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
expect(isAuthError('401 Unauthorized', rateLimitParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNetworkError', () => {
|
||||
it('should return true for network errors', () => {
|
||||
expect(isNetworkError('Network error')).toBe(true);
|
||||
expect(isNetworkError('Failed to fetch')).toBe(true);
|
||||
expect(isNetworkError('ECONNREFUSED')).toBe(true);
|
||||
expect(isNetworkError('Connection timeout')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-network errors', () => {
|
||||
expect(isNetworkError('401 Unauthorized')).toBe(false);
|
||||
expect(isNetworkError('rate limit exceeded')).toBe(false);
|
||||
expect(isNetworkError('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isNetworkError(null)).toBe(false);
|
||||
expect(isNetworkError(undefined)).toBe(false);
|
||||
expect(isNetworkError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'network' as const, message: 'test' };
|
||||
expect(isNetworkError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isNetworkError(null, parsedInfo)).toBe(true);
|
||||
expect(isNetworkError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isNetworkError('Network error', authParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRecoverableError', () => {
|
||||
it('should return true for recoverable errors (rate_limit, network, unknown)', () => {
|
||||
expect(isRecoverableError('rate limit exceeded')).toBe(true);
|
||||
expect(isRecoverableError('Network error')).toBe(true);
|
||||
expect(isRecoverableError('Unknown error occurred')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-recoverable errors (auth, permission, not_found)', () => {
|
||||
expect(isRecoverableError('401 Unauthorized')).toBe(false);
|
||||
expect(isRecoverableError('403 Forbidden')).toBe(false);
|
||||
expect(isRecoverableError('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isRecoverableError(null)).toBe(false);
|
||||
expect(isRecoverableError(undefined)).toBe(false);
|
||||
expect(isRecoverableError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
const networkInfo = { type: 'network' as const, message: 'test' };
|
||||
const unknownInfo = { type: 'unknown' as const, message: 'test' };
|
||||
expect(isRecoverableError('unrelated error', rateLimitInfo)).toBe(true);
|
||||
expect(isRecoverableError(null, networkInfo)).toBe(true);
|
||||
expect(isRecoverableError(undefined, unknownInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type is non-recoverable', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
const permissionParsedInfo = { type: 'permission' as const, message: 'test' };
|
||||
const notFoundParsedInfo = { type: 'not_found' as const, message: 'test' };
|
||||
expect(isRecoverableError('Network error', authParsedInfo)).toBe(false);
|
||||
expect(isRecoverableError('rate limit exceeded', permissionParsedInfo)).toBe(false);
|
||||
expect(isRecoverableError('unknown', notFoundParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiresSettingsAction', () => {
|
||||
it('should return true for errors requiring settings action (auth, permission)', () => {
|
||||
expect(requiresSettingsAction('401 Unauthorized')).toBe(true);
|
||||
expect(requiresSettingsAction('403 Forbidden')).toBe(true);
|
||||
expect(requiresSettingsAction('Invalid token')).toBe(true);
|
||||
expect(requiresSettingsAction('403 Forbidden - missing scopes: repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for errors not requiring settings (rate_limit, network, not_found, unknown)', () => {
|
||||
expect(requiresSettingsAction('rate limit exceeded')).toBe(false);
|
||||
expect(requiresSettingsAction('Network error')).toBe(false);
|
||||
expect(requiresSettingsAction('404 Not Found')).toBe(false);
|
||||
expect(requiresSettingsAction('Unknown error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(requiresSettingsAction(null)).toBe(false);
|
||||
expect(requiresSettingsAction(undefined)).toBe(false);
|
||||
expect(requiresSettingsAction('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const authInfo = { type: 'auth' as const, message: 'test' };
|
||||
const permissionInfo = { type: 'permission' as const, message: 'test' };
|
||||
expect(requiresSettingsAction('unrelated error', authInfo)).toBe(true);
|
||||
expect(requiresSettingsAction(null, permissionInfo)).toBe(true);
|
||||
expect(requiresSettingsAction(undefined, authInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type does not require settings', () => {
|
||||
const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
const networkInfo = { type: 'network' as const, message: 'test' };
|
||||
const notFoundInfo = { type: 'not_found' as const, message: 'test' };
|
||||
expect(requiresSettingsAction('401 Unauthorized', rateLimitInfo)).toBe(false);
|
||||
expect(requiresSettingsAction('403 Forbidden', networkInfo)).toBe(false);
|
||||
expect(requiresSettingsAction('invalid token', notFoundInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-cutting concerns', () => {
|
||||
describe('consistency between parseGitHubError and helper functions', () => {
|
||||
it('should have consistent rate_limit detection', () => {
|
||||
const error = 'rate limit exceeded';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('rate_limit');
|
||||
expect(isRateLimitError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent auth detection', () => {
|
||||
const error = '401 Unauthorized';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('auth');
|
||||
expect(isAuthError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent network detection', () => {
|
||||
const error = 'Network error';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('network');
|
||||
expect(isNetworkError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent recoverable classification', () => {
|
||||
const errors = ['rate limit exceeded', 'Network error', 'Unknown error'];
|
||||
for (const error of errors) {
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(isRecoverableError(error)).toBe(['rate_limit', 'network', 'unknown'].includes(parsed.type));
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent settings action classification', () => {
|
||||
const errors = ['401 Unauthorized', '403 Forbidden'];
|
||||
for (const error of errors) {
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(requiresSettingsAction(error)).toBe(['auth', 'permission'].includes(parsed.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusCode extraction', () => {
|
||||
it('should extract 403 for rate_limit errors', () => {
|
||||
const result = parseGitHubError('rate limit exceeded');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should extract 401 for auth errors', () => {
|
||||
const result = parseGitHubError('Bad credentials');
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should extract 404 for not_found errors', () => {
|
||||
const result = parseGitHubError('Not found');
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should extract 403 for permission errors', () => {
|
||||
const result = parseGitHubError('Forbidden');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should extract status code from message when present', () => {
|
||||
const result = parseGitHubError('HTTP 429 Too Many Requests');
|
||||
expect(result.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('should not extract invalid status codes', () => {
|
||||
const result = parseGitHubError('Error 999');
|
||||
expect(result.statusCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* GitHub API error parser utility.
|
||||
* Parses raw error strings to classify GitHub API errors and extract metadata.
|
||||
*/
|
||||
|
||||
import type { GitHubErrorType, GitHubErrorInfo } from '../types';
|
||||
|
||||
/**
|
||||
* Maximum length for raw error messages stored in GitHubErrorInfo.
|
||||
* Truncates to prevent memory bloat and UI issues.
|
||||
*/
|
||||
const MAX_RAW_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Patterns for rate limit errors (HTTP 403 with rate limit context).
|
||||
* Note: Pattern 1 covers all "rate limit" variations (api rate limit exceeded,
|
||||
* abuse rate limit, secondary rate limit, etc.) via substring matching.
|
||||
*/
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
/rate\s*limit/i, // Covers all variations containing "rate limit"
|
||||
/too\s*many\s*requests/i,
|
||||
/403.*rate/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for authentication errors (HTTP 401)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives.
|
||||
*/
|
||||
const AUTH_PATTERNS = [
|
||||
/unauthorized/i,
|
||||
/bad\s*credentials/i,
|
||||
/authentication\s*failed/i,
|
||||
/invalid\s*(oauth\s*)?token/i,
|
||||
/token\s*(is\s*)?(invalid|expired|required)/i,
|
||||
/not\s*authenticated/i,
|
||||
/requires\s*authentication/i, // GitHub 401 response body
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for permission/scope errors (HTTP 403 with scope context)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives.
|
||||
*/
|
||||
const PERMISSION_PATTERNS = [
|
||||
/forbidden/i,
|
||||
/permission\s*denied/i,
|
||||
/insufficient\s*(scope|permission)/i,
|
||||
/access\s*denied/i,
|
||||
/repository\s*access\s*denied/i,
|
||||
/not\s*authorized\s*to\s*access/i,
|
||||
/requires\s*(admin|write|read)\s*access/i,
|
||||
/missing\s*required\s*scope/i,
|
||||
// Matches "requires: repo" or "requires workflow" for OAuth scope context
|
||||
// Uses specific scope names to avoid matching "requires authentication" (auth error)
|
||||
/requires[:\s]+(?:repo|admin|write|read|workflow|org|gist|notification|user|project|package|delete|discussion)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for not found errors (HTTP 404)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives (e.g., "Issue #404").
|
||||
*/
|
||||
const NOT_FOUND_PATTERNS = [
|
||||
/not\s*found/i,
|
||||
/no\s*such\s*(repository|repo|issue|resource)/i,
|
||||
/does\s*not\s*exist/i,
|
||||
/repository\s*not\s*found/i,
|
||||
/user\s*not\s*found/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for network/connectivity errors
|
||||
*/
|
||||
const NETWORK_PATTERNS = [
|
||||
/network\s*(error|failed|unreachable)/i,
|
||||
/failed\s*to\s*fetch/i,
|
||||
/enetunreach/i,
|
||||
/econnrefused/i,
|
||||
/econnreset/i,
|
||||
/etimedout/i,
|
||||
/dns\s*(error|failed)/i,
|
||||
/offline/i,
|
||||
/no\s*internet/i,
|
||||
/unable\s*to\s*connect/i,
|
||||
/connection\s*(refused|reset|timeout|failed)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Pattern to extract required OAuth scopes from error messages
|
||||
* Matches formats like:
|
||||
* - "requires: repo, read:org"
|
||||
* - "missing scopes: repo, workflow"
|
||||
* - "X-Accepted-OAuth-Scopes: repo"
|
||||
* Stops at sentence boundaries or non-scope characters
|
||||
*/
|
||||
const REQUIRED_SCOPES_PATTERN = /(?:requires?[:\s]*|missing\s*scopes?[:\s]*|X-Accepted-OAuth-Scopes[:\s]*)([a-z0-9_:]+(?:[,\s]+[a-z0-9_:]+)*)/i;
|
||||
|
||||
/**
|
||||
* Pattern to extract HTTP status code from error messages.
|
||||
* Matches status codes preceded by HTTP context keywords or at string start
|
||||
* (for common error formats like "403 Forbidden").
|
||||
*/
|
||||
const STATUS_CODE_PATTERN = /(?:^|HTTP\s*|status[:\s]*|error[:\s]*|code[:\s]*)\b([1-5]\d{2})\b/i;
|
||||
|
||||
/**
|
||||
* Sanitize error output to a reasonable length.
|
||||
* Prevents memory bloat and UI issues from very long error messages.
|
||||
*/
|
||||
function sanitizeRawError(error: string): string {
|
||||
if (error.length > MAX_RAW_ERROR_LENGTH) {
|
||||
return error.substring(0, MAX_RAW_ERROR_LENGTH) + '...';
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum reasonable reset duration in seconds (24 hours).
|
||||
* Prevents malformed error strings from creating far-future dates.
|
||||
*/
|
||||
const MAX_RESET_SECONDS = 86400;
|
||||
|
||||
/**
|
||||
* Extract rate limit reset time from error message.
|
||||
* Parses various formats and returns a Date object if found.
|
||||
* Handles both absolute timestamps and relative durations ("in X seconds").
|
||||
*/
|
||||
function extractRateLimitResetTime(error: string): Date | undefined {
|
||||
// First, try to match relative duration pattern (e.g., "reset in 3600 seconds")
|
||||
const relativePattern = /reset[s]?\s*in[:\s]*(\d+)\s*seconds?/i;
|
||||
const relativeMatch = error.match(relativePattern);
|
||||
if (relativeMatch) {
|
||||
const seconds = parseInt(relativeMatch[1], 10);
|
||||
// Validate: positive, non-NaN, and within reasonable bounds (24 hours max)
|
||||
if (!Number.isNaN(seconds) && seconds > 0 && seconds <= MAX_RESET_SECONDS) {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Then try absolute timestamp pattern
|
||||
const absolutePattern = /(?:reset[s]?\s*at[:\s]*|X-RateLimit-Reset[:\s]*)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?|\d+)/i;
|
||||
const match = error.match(absolutePattern);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetValue = match[1].trim();
|
||||
|
||||
// Check if it's an ISO date string
|
||||
if (resetValue.includes('-') && resetValue.includes('T')) {
|
||||
const date = new Date(resetValue);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
// Validate: within reasonable bounds (24 hours max from now)
|
||||
if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
// Check if it's a Unix timestamp (seconds or milliseconds)
|
||||
const numericValue = parseInt(resetValue, 10);
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
// GitHub API uses seconds, JavaScript uses milliseconds
|
||||
// Values > 1e12 are likely milliseconds already
|
||||
const timestamp = numericValue > 1e12 ? numericValue : numericValue * 1000;
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
// Validate: within reasonable bounds (24 hours max from now)
|
||||
if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract required OAuth scopes from error message.
|
||||
* Returns an array of scope strings if found.
|
||||
*/
|
||||
function extractRequiredScopes(error: string): string[] | undefined {
|
||||
const match = error.match(REQUIRED_SCOPES_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scopes = match[1]
|
||||
.split(/[,\s]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
return scopes.length > 0 ? scopes : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTTP status code from error message.
|
||||
*/
|
||||
function extractStatusCode(error: string): number | undefined {
|
||||
const match = error.match(STATUS_CODE_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10);
|
||||
// Only return valid HTTP status codes
|
||||
if (code >= 100 && code < 600) {
|
||||
return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error matches any of the given patterns.
|
||||
*/
|
||||
function matchesPatterns(error: string, patterns: RegExp[]): boolean {
|
||||
return patterns.some(pattern => pattern.test(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for rate limit errors.
|
||||
*/
|
||||
function getRateLimitMessage(_error: string, resetTime?: Date): string {
|
||||
if (resetTime) {
|
||||
const now = new Date();
|
||||
const diffMs = resetTime.getTime() - now.getTime();
|
||||
|
||||
if (diffMs > 0) {
|
||||
const diffMins = Math.ceil(diffMs / 60000);
|
||||
if (diffMins < 60) {
|
||||
return `GitHub API rate limit reached. Please wait ${diffMins} minute${diffMins !== 1 ? 's' : ''} before trying again.`;
|
||||
}
|
||||
const diffHours = Math.ceil(diffMins / 60);
|
||||
return `GitHub API rate limit reached. Rate limit resets in approximately ${diffHours} hour${diffHours !== 1 ? 's' : ''}.`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'GitHub API rate limit reached. Please wait a moment before trying again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for authentication errors.
|
||||
*/
|
||||
function getAuthMessage(): string {
|
||||
return 'GitHub authentication failed. Please check your GitHub token in Settings and try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for permission errors.
|
||||
*/
|
||||
function getPermissionMessage(scopes?: string[]): string {
|
||||
if (scopes && scopes.length > 0) {
|
||||
return `GitHub permission denied. Your token is missing required scopes: ${scopes.join(', ')}. Please update your GitHub token in Settings.`;
|
||||
}
|
||||
return 'GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for not found errors.
|
||||
*/
|
||||
function getNotFoundMessage(): string {
|
||||
return 'The requested GitHub resource was not found. Please verify the repository exists and you have access to it.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for network errors.
|
||||
*/
|
||||
function getNetworkMessage(): string {
|
||||
return 'Unable to connect to GitHub. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for unknown errors.
|
||||
*/
|
||||
function getUnknownMessage(): string {
|
||||
return 'An unexpected error occurred while communicating with GitHub. Please try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type based on pattern matching and optional status code.
|
||||
* Priority: rate_limit > auth > permission > not_found > network > unknown
|
||||
* Note: Permission checks run before not_found to properly classify 403 responses.
|
||||
* Status code fallback takes priority over network patterns since HTTP status
|
||||
* codes are more specific than generic network error text.
|
||||
* @param error - The error string to classify
|
||||
* @param statusCode - Optional HTTP status code extracted with context (helps classify when text patterns don't match)
|
||||
*/
|
||||
function classifyError(error: string, statusCode?: number): GitHubErrorType {
|
||||
// Check rate limit first (403 can also be permission, but rate limit is more specific)
|
||||
if (matchesPatterns(error, RATE_LIMIT_PATTERNS)) {
|
||||
return 'rate_limit';
|
||||
}
|
||||
|
||||
// Check auth (401 is always auth)
|
||||
if (matchesPatterns(error, AUTH_PATTERNS)) {
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
// Check permission (403 without rate limit context) before not_found
|
||||
// to properly classify 403 responses that might contain "not found" text
|
||||
if (matchesPatterns(error, PERMISSION_PATTERNS)) {
|
||||
return 'permission';
|
||||
}
|
||||
|
||||
// Check not found (404 is always not_found)
|
||||
if (matchesPatterns(error, NOT_FOUND_PATTERNS)) {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
// Use status code fallback BEFORE network patterns
|
||||
// HTTP status codes are more specific than generic network error text
|
||||
if (statusCode === 401) return 'auth';
|
||||
if (statusCode === 403) return 'permission';
|
||||
if (statusCode === 404) return 'not_found';
|
||||
|
||||
// Check network errors (only if no status code fallback matched)
|
||||
if (matchesPatterns(error, NETWORK_PATTERNS)) {
|
||||
return 'network';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GitHub API error string and return classified error information.
|
||||
*
|
||||
* IMPORTANT: The returned `message` field contains hardcoded English strings
|
||||
* intended ONLY as a fallback defaultValue for i18n translation. Consumers
|
||||
* should use the `type` field to look up the appropriate translation key
|
||||
* (e.g., 'githubErrors.rateLimitMessage') via react-i18next rather than
|
||||
* displaying `message` directly. This ensures proper localization.
|
||||
*
|
||||
* Translation key mapping by type:
|
||||
* - rate_limit → 'githubErrors.rateLimitMessage' (or rateLimitMessageMinutes/Hours)
|
||||
* - auth → 'githubErrors.authMessage'
|
||||
* - permission → 'githubErrors.permissionMessage' (or permissionMessageScopes)
|
||||
* - not_found → 'githubErrors.notFoundMessage'
|
||||
* - network → 'githubErrors.networkMessage'
|
||||
* - unknown → 'githubErrors.unknownMessage'
|
||||
*
|
||||
* @param error - The raw error string (typically from issues-store error state)
|
||||
* @returns GitHubErrorInfo object with classified type, user-friendly message, and metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const errorInfo = parseGitHubError('GitHub API error: 403 - API rate limit exceeded');
|
||||
* // Use type to get i18n key, message only as fallback:
|
||||
* // t(`githubErrors.${errorInfo.type}Message`, { defaultValue: errorInfo.message })
|
||||
* ```
|
||||
*/
|
||||
export function parseGitHubError(error: string | null | undefined): GitHubErrorInfo {
|
||||
// Handle null/undefined/empty errors
|
||||
if (!error || typeof error !== 'string' || error.trim() === '') {
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: getUnknownMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedError = error.trim();
|
||||
// Extract status code first so we can use it for classification fallback
|
||||
const statusCode = extractStatusCode(trimmedError);
|
||||
const errorType = classifyError(trimmedError, statusCode);
|
||||
|
||||
switch (errorType) {
|
||||
case 'rate_limit': {
|
||||
const resetTime = extractRateLimitResetTime(trimmedError);
|
||||
return {
|
||||
type: 'rate_limit',
|
||||
message: getRateLimitMessage(trimmedError, resetTime),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
rateLimitResetTime: resetTime,
|
||||
statusCode: statusCode ?? 403,
|
||||
};
|
||||
}
|
||||
|
||||
case 'auth':
|
||||
return {
|
||||
type: 'auth',
|
||||
message: getAuthMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode: statusCode ?? 401,
|
||||
};
|
||||
|
||||
case 'permission': {
|
||||
const scopes = extractRequiredScopes(trimmedError);
|
||||
return {
|
||||
type: 'permission',
|
||||
message: getPermissionMessage(scopes),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
requiredScopes: scopes,
|
||||
statusCode: statusCode ?? 403,
|
||||
};
|
||||
}
|
||||
|
||||
case 'not_found':
|
||||
return {
|
||||
type: 'not_found',
|
||||
message: getNotFoundMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode: statusCode ?? 404,
|
||||
};
|
||||
|
||||
case 'network':
|
||||
return {
|
||||
type: 'network',
|
||||
message: getNetworkMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: getUnknownMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a rate limit error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isRateLimitError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'rate_limit';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'rate_limit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isAuthError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'auth';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'auth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isNetworkError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'network';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'network';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable (user can retry).
|
||||
* Rate limit, network, and unknown errors are considered recoverable.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isRecoverableError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return ['rate_limit', 'network', 'unknown'].includes(parsedInfo.type);
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
const errorType = classifyError(trimmed, extractStatusCode(trimmed));
|
||||
return ['rate_limit', 'network', 'unknown'].includes(errorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error requires user action in settings.
|
||||
* Auth and permission errors require settings changes.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function requiresSettingsAction(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return ['auth', 'permission'].includes(parsedInfo.type);
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
const errorType = classifyError(trimmed, extractStatusCode(trimmed));
|
||||
return ['auth', 'permission'].includes(errorType);
|
||||
}
|
||||
@@ -19,3 +19,13 @@ export function filterIssuesBySearch(issues: GitHubIssue[], searchQuery: string)
|
||||
issue.body?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export GitHub error parser utilities
|
||||
export {
|
||||
parseGitHubError,
|
||||
isRateLimitError,
|
||||
isAuthError,
|
||||
isNetworkError,
|
||||
isRecoverableError,
|
||||
requiresSettingsAction,
|
||||
} from './github-error-parser';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Label } from '../ui/label';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT, UI_SCALE_STEP } from '../../../shared/constants';
|
||||
import type { AppSettings } from '../../../shared/types';
|
||||
import type { AppSettings, GpuAcceleration } from '../../../shared/types';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
|
||||
interface DisplaySettingsProps {
|
||||
@@ -274,6 +274,45 @@ export function DisplaySettings({ settings, onSettingsChange }: DisplaySettingsP
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPU Acceleration Setting */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between max-w-md">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="gpuAcceleration" className="text-sm font-medium text-foreground">
|
||||
{t('gpuAcceleration.label')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('gpuAcceleration.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={settings.gpuAcceleration || 'off'}
|
||||
onValueChange={(value) => onSettingsChange({
|
||||
...settings,
|
||||
gpuAcceleration: value as GpuAcceleration
|
||||
})}
|
||||
>
|
||||
<SelectTrigger id="gpuAcceleration" className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
<SelectItem value="auto">
|
||||
{t('gpuAcceleration.auto')}
|
||||
</SelectItem>
|
||||
<SelectItem value="on">
|
||||
{t('gpuAcceleration.on')}
|
||||
</SelectItem>
|
||||
<SelectItem value="off">
|
||||
{t('gpuAcceleration.off')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('gpuAcceleration.helperText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import '../../../../shared/i18n';
|
||||
import { DisplaySettings } from '../DisplaySettings';
|
||||
import type { AppSettings } from '../../../../shared/types';
|
||||
|
||||
// Mock the settings store
|
||||
vi.mock('../../../stores/settings-store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({
|
||||
updateSettings: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
// Track onValueChange callbacks per Select instance, keyed by the SelectTrigger id
|
||||
let selectCallbacks: Map<string, (v: string) => void> = new Map();
|
||||
let currentSelectCallback: ((v: string) => void) | null = null;
|
||||
|
||||
// Mock Radix Select to make it testable in jsdom (portals don't work in jsdom)
|
||||
vi.mock('../../ui/select', () => {
|
||||
return {
|
||||
Select: ({ value, onValueChange, children }: { value: string; onValueChange: (v: string) => void; children: React.ReactNode }) => {
|
||||
currentSelectCallback = onValueChange;
|
||||
return <div data-value={value}>{children}</div>;
|
||||
},
|
||||
SelectTrigger: ({ id, children }: { id?: string; className?: string; children: React.ReactNode }) => {
|
||||
if (id && currentSelectCallback) {
|
||||
selectCallbacks.set(id, currentSelectCallback);
|
||||
currentSelectCallback = null;
|
||||
}
|
||||
return <button data-testid={`select-trigger-${id || 'unknown'}`}>{children}</button>;
|
||||
},
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { className?: string; children: React.ReactNode }) => (
|
||||
<div data-testid="select-content">{children}</div>
|
||||
),
|
||||
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
|
||||
<div role="option" data-testid={`select-item-${value}`} data-value={value}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
uiScale: 100,
|
||||
logOrder: 'chronological',
|
||||
gpuAcceleration: 'auto'
|
||||
} as AppSettings;
|
||||
|
||||
describe('DisplaySettings - GPU Acceleration Dropdown', () => {
|
||||
let mockOnSettingsChange: (settings: AppSettings) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectCallbacks = new Map();
|
||||
currentSelectCallback = null;
|
||||
mockOnSettingsChange = vi.fn();
|
||||
});
|
||||
|
||||
it('should render the GPU acceleration dropdown with all 3 options', () => {
|
||||
render(
|
||||
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('GPU Acceleration')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('select-item-auto')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('select-item-on')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('select-item-off')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the correct translated labels for each option', () => {
|
||||
render(
|
||||
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Auto (use WebGL when supported)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Always on')).toBeInTheDocument();
|
||||
expect(screen.getByText('Off (default)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the current GPU acceleration value from settings', () => {
|
||||
const settingsWithOn: AppSettings = { ...defaultSettings, gpuAcceleration: 'on' };
|
||||
|
||||
render(
|
||||
<DisplaySettings settings={settingsWithOn} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
// The GPU acceleration select is identified by its trigger id
|
||||
const gpuTrigger = screen.getByTestId('select-trigger-gpuAcceleration');
|
||||
const gpuSelect = gpuTrigger.closest('[data-value]');
|
||||
expect(gpuSelect).toHaveAttribute('data-value', 'on');
|
||||
});
|
||||
|
||||
it('should default to "off" when gpuAcceleration is not set', () => {
|
||||
const settingsWithoutGpu: AppSettings = { ...defaultSettings, gpuAcceleration: undefined };
|
||||
|
||||
render(
|
||||
<DisplaySettings settings={settingsWithoutGpu} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
const gpuTrigger = screen.getByTestId('select-trigger-gpuAcceleration');
|
||||
const gpuSelect = gpuTrigger.closest('[data-value]');
|
||||
expect(gpuSelect).toHaveAttribute('data-value', 'off');
|
||||
});
|
||||
|
||||
it('should call onSettingsChange with gpuAcceleration "on" when selected', () => {
|
||||
render(
|
||||
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
selectCallbacks.get('gpuAcceleration')!('on');
|
||||
|
||||
expect(mockOnSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuAcceleration: 'on' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onSettingsChange with gpuAcceleration "off" when selected', () => {
|
||||
render(
|
||||
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
selectCallbacks.get('gpuAcceleration')!('off');
|
||||
|
||||
expect(mockOnSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuAcceleration: 'off' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onSettingsChange with gpuAcceleration "auto" when selected', () => {
|
||||
const settingsWithOff: AppSettings = { ...defaultSettings, gpuAcceleration: 'off' };
|
||||
|
||||
render(
|
||||
<DisplaySettings settings={settingsWithOff} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
selectCallbacks.get('gpuAcceleration')!('auto');
|
||||
|
||||
expect(mockOnSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuAcceleration: 'auto' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should render the GPU acceleration description text', () => {
|
||||
render(
|
||||
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Use WebGL for terminal rendering (experimental, faster with many terminals)')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -66,11 +66,35 @@ vi.mock('@xterm/addon-serialize', () => ({
|
||||
vi.mock('../../../../lib/terminal-buffer-manager', () => ({
|
||||
terminalBufferManager: {
|
||||
get: vi.fn(() => ''),
|
||||
getAndClear: vi.fn(() => ''),
|
||||
set: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock WebGL context manager
|
||||
const mockWebglRegister = vi.fn();
|
||||
const mockWebglAcquire = vi.fn();
|
||||
const mockWebglUnregister = vi.fn();
|
||||
vi.mock('../../../lib/webgl-context-manager', () => ({
|
||||
webglContextManager: {
|
||||
register: (...args: unknown[]) => mockWebglRegister(...args),
|
||||
acquire: (...args: unknown[]) => mockWebglAcquire(...args),
|
||||
unregister: (...args: unknown[]) => mockWebglUnregister(...args),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock settings store (for gpuAcceleration setting)
|
||||
const mockSettingsStoreState = {
|
||||
settings: { gpuAcceleration: 'auto' as string | undefined }
|
||||
};
|
||||
vi.mock('../../../stores/settings-store', () => ({
|
||||
useSettingsStore: Object.assign(vi.fn(), {
|
||||
getState: () => mockSettingsStoreState,
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock navigator.platform for platform detection
|
||||
const originalNavigatorPlatform = navigator.platform;
|
||||
|
||||
@@ -837,3 +861,186 @@ describe('useXterm keyboard handlers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useXterm WebGL context management', () => {
|
||||
// Mock requestAnimationFrame for jsdom environment
|
||||
const originalRequestAnimationFrame = global.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = global.cancelAnimationFrame;
|
||||
|
||||
beforeAll(() => {
|
||||
global.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number);
|
||||
global.cancelAnimationFrame = vi.fn((id: number) => clearTimeout(id));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
global.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset gpuAcceleration to default
|
||||
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(function() {
|
||||
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
|
||||
});
|
||||
|
||||
// Mock window.electronAPI
|
||||
(window as unknown as { electronAPI: unknown }).electronAPI = {
|
||||
sendTerminalInput: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to render useXterm and wait for initialization
|
||||
*/
|
||||
async function renderUseXterm(terminalId = 'test-webgl-terminal') {
|
||||
// Set up XTerm mock with dispose tracking
|
||||
const mockDispose = vi.fn();
|
||||
(XTerm as unknown as Mock).mockImplementation(function() {
|
||||
return {
|
||||
open: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ''),
|
||||
paste: vi.fn(),
|
||||
input: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onResize: vi.fn(),
|
||||
dispose: mockDispose,
|
||||
write: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 1,
|
||||
letterSpacing: 0,
|
||||
theme: { cursorAccent: '#000000' },
|
||||
scrollback: 1000
|
||||
},
|
||||
refresh: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
const { FitAddon } = await import('@xterm/addon-fit');
|
||||
(FitAddon as unknown as Mock).mockImplementation(function() {
|
||||
return { fit: vi.fn(), dispose: vi.fn() };
|
||||
});
|
||||
|
||||
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
||||
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
|
||||
return {};
|
||||
});
|
||||
|
||||
const { SerializeAddon } = await import('@xterm/addon-serialize');
|
||||
(SerializeAddon as unknown as Mock).mockImplementation(function() {
|
||||
return { serialize: vi.fn(() => ''), dispose: vi.fn() };
|
||||
});
|
||||
|
||||
let disposeHook: (() => void) | null = null;
|
||||
|
||||
const TestWrapper = () => {
|
||||
const result = useXterm({ terminalId });
|
||||
// Expose dispose via ref so tests can call it
|
||||
disposeHook = result.dispose;
|
||||
return React.createElement('div', { ref: result.terminalRef });
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(React.createElement(TestWrapper));
|
||||
});
|
||||
|
||||
return { disposeHook: () => disposeHook?.() };
|
||||
}
|
||||
|
||||
it('should lazily import and acquire WebGL context when gpuAcceleration is "auto"', async () => {
|
||||
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
|
||||
|
||||
await renderUseXterm('terminal-auto');
|
||||
// Flush the dynamic import() promise + microtasks
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-auto', expect.anything());
|
||||
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-auto');
|
||||
});
|
||||
|
||||
it('should lazily import and acquire WebGL context when gpuAcceleration is "on"', async () => {
|
||||
mockSettingsStoreState.settings.gpuAcceleration = 'on';
|
||||
|
||||
await renderUseXterm('terminal-on');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-on', expect.anything());
|
||||
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-on');
|
||||
});
|
||||
|
||||
it('should NOT import WebGL module at all when gpuAcceleration is "off"', async () => {
|
||||
mockSettingsStoreState.settings.gpuAcceleration = 'off';
|
||||
|
||||
await renderUseXterm('terminal-off');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
// When off, the dynamic import() never fires — no GPU code runs
|
||||
expect(mockWebglRegister).not.toHaveBeenCalled();
|
||||
expect(mockWebglAcquire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unregister WebGL context on terminal disposal', async () => {
|
||||
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
|
||||
|
||||
const { disposeHook } = await renderUseXterm('terminal-dispose');
|
||||
// Flush the dynamic import so the manager ref is populated
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-dispose', expect.anything());
|
||||
|
||||
// Dispose the terminal
|
||||
act(() => {
|
||||
disposeHook();
|
||||
});
|
||||
|
||||
expect(mockWebglUnregister).toHaveBeenCalledWith('terminal-dispose');
|
||||
});
|
||||
|
||||
it('should NOT unregister on disposal when WebGL was never loaded (off)', async () => {
|
||||
mockSettingsStoreState.settings.gpuAcceleration = 'off';
|
||||
|
||||
const { disposeHook } = await renderUseXterm('terminal-off-dispose');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
// Dispose the terminal
|
||||
act(() => {
|
||||
disposeHook();
|
||||
});
|
||||
|
||||
// WebGL was never loaded, so unregister should not be called
|
||||
expect(mockWebglUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to "off" when gpuAcceleration is undefined (upgrading users)', async () => {
|
||||
mockSettingsStoreState.settings.gpuAcceleration = undefined;
|
||||
|
||||
await renderUseXterm('terminal-undefined');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
// When undefined, the ?? 'off' fallback means no WebGL import at all
|
||||
expect(mockWebglRegister).not.toHaveBeenCalled();
|
||||
expect(mockWebglAcquire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { isWindows as checkIsWindows, isLinux as checkIsLinux } from '../../lib/
|
||||
import { debounce } from '../../lib/debounce';
|
||||
import { DEFAULT_TERMINAL_THEME } from '../../lib/terminal-theme';
|
||||
import { debugLog, debugError } from '../../../shared/utils/debug-logger';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import type { WebGLContextManagerType } from '../../lib/webgl-context-manager';
|
||||
|
||||
interface UseXtermOptions {
|
||||
terminalId: string;
|
||||
@@ -58,6 +60,8 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
const commandBufferRef = useRef<string>('');
|
||||
const isDisposedRef = useRef<boolean>(false);
|
||||
const dimensionsReadyCalledRef = useRef<boolean>(false);
|
||||
// Lazily-loaded WebGL context manager — only populated when gpuAcceleration !== 'off'
|
||||
const webglManagerRef = useRef<WebGLContextManagerType | null>(null);
|
||||
const onResizeRef = useRef(onResize);
|
||||
const [dimensions, setDimensions] = useState<{ cols: number; rows: number }>({ cols: 80, rows: 24 });
|
||||
|
||||
@@ -117,6 +121,29 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
|
||||
xterm.open(terminalRef.current);
|
||||
|
||||
// WebGL acceleration: lazily load the WebGL module and acquire a context.
|
||||
// The dynamic import() ensures NO GPU code (WebGL2 probing, context creation)
|
||||
// runs unless the user has explicitly enabled GPU acceleration.
|
||||
// This prevents GPU process instability on systems where WebGL2 is problematic
|
||||
// (e.g., Apple Silicon Macs with certain macOS / Electron combinations).
|
||||
const gpuAcceleration = useSettingsStore.getState().settings.gpuAcceleration ?? 'off';
|
||||
debugLog(`[useXterm] WebGL check for ${terminalId}: gpuAcceleration=${gpuAcceleration}`);
|
||||
if (gpuAcceleration !== 'off') {
|
||||
import('../../lib/webgl-context-manager')
|
||||
.then(({ webglContextManager }) => {
|
||||
// Guard: terminal may have been disposed while the import was resolving
|
||||
if (isDisposedRef.current) return;
|
||||
webglManagerRef.current = webglContextManager;
|
||||
webglContextManager.register(terminalId, xterm);
|
||||
webglContextManager.acquire(terminalId);
|
||||
debugLog(`[useXterm] WebGL acquired for ${terminalId}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
// WebGL is a progressive enhancement — terminal works fine without it
|
||||
debugError(`[useXterm] WebGL initialization failed for ${terminalId}, falling back to canvas renderer:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Platform detection for copy/paste shortcuts
|
||||
// Use existing os-detection module instead of custom implementation
|
||||
const isWindows = checkIsWindows();
|
||||
@@ -139,11 +166,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) => {
|
||||
@@ -545,6 +579,16 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
// Serialize buffer before disposing to preserve ANSI formatting
|
||||
serializeBuffer();
|
||||
|
||||
// Release WebGL context before disposing addons and xterm (only if WebGL was loaded)
|
||||
if (webglManagerRef.current) {
|
||||
try {
|
||||
webglManagerRef.current.unregister(terminalId);
|
||||
} catch (error) {
|
||||
debugError(`[useXterm] WebGL cleanup failed for ${terminalId}:`, error);
|
||||
}
|
||||
webglManagerRef.current = null;
|
||||
}
|
||||
|
||||
// Dispose addons explicitly before disposing xterm
|
||||
// While xterm.dispose() handles loaded addons, explicit disposal ensures
|
||||
// resources are freed in a predictable order and prevents potential leaks
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -184,6 +184,9 @@ class WebGLContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Type alias for the manager — used by consumers that import lazily via dynamic import() */
|
||||
export type WebGLContextManagerType = WebGLContextManager;
|
||||
|
||||
// Export singleton instance
|
||||
export const webglContextManager = WebGLContextManager.getInstance();
|
||||
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,7 +67,11 @@ export const DEFAULT_APP_SETTINGS = {
|
||||
// Anonymous error reporting (Sentry) - enabled by default to help improve the app
|
||||
sentryEnabled: true,
|
||||
// Auto-name Claude terminals based on initial message (enabled by default)
|
||||
autoNameClaudeTerminals: true
|
||||
autoNameClaudeTerminals: true,
|
||||
// GPU acceleration for terminal rendering
|
||||
// Default to 'off' until WebGL stability is proven across all GPU drivers.
|
||||
// Users can opt-in via Settings > Display > GPU Acceleration.
|
||||
gpuAcceleration: 'off' as const
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -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",
|
||||
@@ -659,6 +668,28 @@
|
||||
"remote": "Remote"
|
||||
}
|
||||
},
|
||||
"githubErrors": {
|
||||
"rateLimitTitle": "GitHub Rate Limit Reached",
|
||||
"authTitle": "GitHub Authentication Required",
|
||||
"permissionTitle": "GitHub Permission Denied",
|
||||
"notFoundTitle": "GitHub Resource Not Found",
|
||||
"networkTitle": "GitHub Connection Error",
|
||||
"unknownTitle": "GitHub Error",
|
||||
"rateLimitMessage": "GitHub API rate limit reached. Please wait a moment before trying again.",
|
||||
"rateLimitMessageMinutes": "GitHub API rate limit reached. Please wait {{minutes}} minute(s) before trying again.",
|
||||
"rateLimitMessageHours": "GitHub API rate limit reached. Rate limit resets in approximately {{hours}} hour(s).",
|
||||
"authMessage": "GitHub authentication failed. Please check your GitHub token in Settings and try again.",
|
||||
"permissionMessage": "GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.",
|
||||
"permissionMessageScopes": "GitHub permission denied. Your token is missing required scopes: {{scopes}}. Please update your GitHub token in Settings.",
|
||||
"notFoundMessage": "The requested GitHub resource was not found. Please verify the repository exists and you have access to it.",
|
||||
"networkMessage": "Unable to connect to GitHub. Please check your internet connection and try again.",
|
||||
"unknownMessage": "An unexpected error occurred while communicating with GitHub. Please try again.",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
"countdownHoursMinutes": "{{hours}}h {{minutes}}m",
|
||||
"countdownMinutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"rateLimitExpired": "Rate limit has reset. You can retry now.",
|
||||
"requiredScopes": "Required scopes"
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Elapsed",
|
||||
"lastActivity": "Last activity",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -192,6 +192,14 @@
|
||||
"chronological": "Chronological (oldest first)",
|
||||
"reverseChronological": "Reverse-chronological (newest first)"
|
||||
},
|
||||
"gpuAcceleration": {
|
||||
"label": "GPU Acceleration",
|
||||
"description": "Use WebGL for terminal rendering (experimental, faster with many terminals)",
|
||||
"auto": "Auto (use WebGL when supported)",
|
||||
"on": "Always on",
|
||||
"off": "Off (default)",
|
||||
"helperText": "Changes apply to new terminals only"
|
||||
},
|
||||
"general": {
|
||||
"otherAgentSettings": "Other Agent Settings",
|
||||
"otherAgentSettingsDescription": "Additional agent configuration options",
|
||||
|
||||
@@ -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",
|
||||
@@ -659,6 +668,28 @@
|
||||
"remote": "Distante"
|
||||
}
|
||||
},
|
||||
"githubErrors": {
|
||||
"rateLimitTitle": "Limite de débit GitHub atteinte",
|
||||
"authTitle": "Authentification GitHub requise",
|
||||
"permissionTitle": "Permission GitHub refusée",
|
||||
"notFoundTitle": "Ressource GitHub introuvable",
|
||||
"networkTitle": "Erreur de connexion GitHub",
|
||||
"unknownTitle": "Erreur GitHub",
|
||||
"rateLimitMessage": "Limite de débit de l'API GitHub atteinte. Veuillez patienter un moment avant de réessayer.",
|
||||
"rateLimitMessageMinutes": "Limite de débit de l'API GitHub atteinte. Veuillez attendre {{minutes}} minute(s) avant de réessayer.",
|
||||
"rateLimitMessageHours": "Limite de débit de l'API GitHub atteinte. La limite se réinitialise dans environ {{hours}} heure(s).",
|
||||
"authMessage": "Échec de l'authentification GitHub. Veuillez vérifier votre jeton GitHub dans les Paramètres et réessayer.",
|
||||
"permissionMessage": "Permission GitHub refusée. Votre jeton n'a peut-être pas les accès requis. Veuillez vérifier les permissions de votre jeton dans les Paramètres.",
|
||||
"permissionMessageScopes": "Permission GitHub refusée. Votre jeton manque de permissions requises : {{scopes}}. Veuillez mettre à jour votre jeton GitHub dans les Paramètres.",
|
||||
"notFoundMessage": "La ressource GitHub demandée est introuvable. Veuillez vérifier que le dépôt existe et que vous y avez accès.",
|
||||
"networkMessage": "Impossible de se connecter à GitHub. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
"unknownMessage": "Une erreur inattendue s'est produite lors de la communication avec GitHub. Veuillez réessayer.",
|
||||
"resetsIn": "Réinitialisation dans {{time}}",
|
||||
"countdownHoursMinutes": "{{hours}}h {{minutes}}m",
|
||||
"countdownMinutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"rateLimitExpired": "La limite de débit a été réinitialisée. Vous pouvez réessayer maintenant.",
|
||||
"requiredScopes": "Permissions requises"
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Écoulé",
|
||||
"lastActivity": "Dernière activité",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -192,6 +192,14 @@
|
||||
"chronological": "Chronologique (plus ancien en premier)",
|
||||
"reverseChronological": "Chronologique inverse (plus récent en premier)"
|
||||
},
|
||||
"gpuAcceleration": {
|
||||
"label": "Accélération GPU",
|
||||
"description": "Utiliser WebGL pour le rendu des terminaux (expérimental, plus rapide avec plusieurs terminaux)",
|
||||
"auto": "Auto (utiliser WebGL si supporté)",
|
||||
"on": "Toujours activé",
|
||||
"off": "Désactivé (par défaut)",
|
||||
"helperText": "Les modifications s'appliquent uniquement aux nouveaux terminaux"
|
||||
},
|
||||
"general": {
|
||||
"otherAgentSettings": "Autres paramètres de l'agent",
|
||||
"otherAgentSettingsDescription": "Options de configuration supplémentaires de l'agent",
|
||||
|
||||
@@ -294,8 +294,13 @@ export interface AppSettings {
|
||||
seenVersionWarnings?: string[];
|
||||
// Sidebar collapsed state (icons only when true)
|
||||
sidebarCollapsed?: boolean;
|
||||
// GPU acceleration for terminal rendering (WebGL)
|
||||
gpuAcceleration?: GpuAcceleration;
|
||||
}
|
||||
|
||||
// GPU acceleration mode for terminal WebGL rendering
|
||||
export type GpuAcceleration = 'auto' | 'on' | 'off';
|
||||
|
||||
// Auto-Claude Source Environment Configuration (for auto-claude repo .env)
|
||||
export interface SourceEnvConfig {
|
||||
// Claude Authentication (required for ideation, roadmap generation, etc.)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user