Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31c6c27c52 | |||
| 8ab939e11e |
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Auto Claude MCP Server
|
||||
======================
|
||||
|
||||
Control plane for the Auto Claude autonomous coding pipeline.
|
||||
Exposes all backend capabilities via the Model Context Protocol (MCP).
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Auto Claude MCP Server Entry Point
|
||||
===================================
|
||||
|
||||
Usage:
|
||||
python -m mcp_server --project-dir /path/to/project
|
||||
python -m mcp_server --project-dir /path/to/project --transport sse --port 8642
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Configure logging to stderr (stdout is reserved for MCP protocol over stdio)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("mcp_server")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Auto Claude MCP Server - control plane for the autonomous coding pipeline",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
required=True,
|
||||
help="Path to the project directory to manage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio", "sse", "streamable-http"],
|
||||
default="stdio",
|
||||
help="MCP transport to use (default: stdio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8642,
|
||||
help="Port for SSE/HTTP transport (default: 8642)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Host for SSE/HTTP transport (default: 127.0.0.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# Initialize project context (adds backend to sys.path, loads .env)
|
||||
from mcp_server.config import initialize
|
||||
|
||||
initialize(args.project_dir)
|
||||
|
||||
# Import server and register tools AFTER initialization
|
||||
# (tools need backend modules on sys.path)
|
||||
from mcp_server.server import mcp, register_all_tools
|
||||
|
||||
register_all_tools()
|
||||
|
||||
logger.info(
|
||||
"Starting Auto Claude MCP server (transport=%s, project=%s)",
|
||||
args.transport,
|
||||
args.project_dir,
|
||||
)
|
||||
|
||||
# For stdio transport, redirect any stray stdout prints to stderr
|
||||
# to prevent corrupting the MCP JSON-RPC protocol
|
||||
if args.transport == "stdio":
|
||||
# Capture any prints from backend modules that write to stdout
|
||||
_original_stdout = sys.stdout
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
# Run the server
|
||||
if args.transport == "stdio":
|
||||
mcp.run(transport="stdio")
|
||||
elif args.transport == "sse":
|
||||
mcp.run(transport="sse", host=args.host, port=args.port)
|
||||
elif args.transport == "streamable-http":
|
||||
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
MCP Server Configuration
|
||||
========================
|
||||
|
||||
Manages project context and backend initialization for the MCP server.
|
||||
The project directory is set once at startup and used by all tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global project context - set once at server startup
|
||||
_project_dir: Path | None = None
|
||||
_auto_claude_dir: Path | None = None
|
||||
|
||||
|
||||
def initialize(project_dir: str | Path) -> None:
|
||||
"""Initialize the MCP server with a project directory.
|
||||
|
||||
This sets up the Python path so backend modules can be imported,
|
||||
loads the .env file, and validates the project structure.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the user's project directory
|
||||
"""
|
||||
global _project_dir, _auto_claude_dir
|
||||
|
||||
_project_dir = Path(project_dir).resolve()
|
||||
if not _project_dir.is_dir():
|
||||
raise ValueError(f"Project directory does not exist: {_project_dir}")
|
||||
|
||||
# Add backend to sys.path so existing modules can be imported
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# Load .env if present
|
||||
try:
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
env_file = backend_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
except Exception:
|
||||
logger.debug("Could not load .env file (non-critical)")
|
||||
|
||||
# Determine .auto-claude directory
|
||||
auto_claude = _project_dir / ".auto-claude"
|
||||
if not auto_claude.is_dir():
|
||||
# Also check legacy 'auto-claude' (no dot prefix)
|
||||
alt = _project_dir / "auto-claude"
|
||||
if alt.is_dir():
|
||||
auto_claude = alt
|
||||
else:
|
||||
logger.warning(
|
||||
"No .auto-claude directory found in %s. "
|
||||
"Some tools may not work until the project is initialized.",
|
||||
_project_dir,
|
||||
)
|
||||
_auto_claude_dir = auto_claude
|
||||
|
||||
logger.info("MCP server initialized for project: %s", _project_dir)
|
||||
|
||||
|
||||
def get_project_dir() -> Path:
|
||||
"""Get the active project directory. Raises if not initialized."""
|
||||
if _project_dir is None:
|
||||
raise RuntimeError(
|
||||
"MCP server not initialized. Call config.initialize() first."
|
||||
)
|
||||
return _project_dir
|
||||
|
||||
|
||||
def get_auto_claude_dir() -> Path:
|
||||
"""Get the .auto-claude directory for the active project."""
|
||||
if _auto_claude_dir is None:
|
||||
raise RuntimeError(
|
||||
"MCP server not initialized. Call config.initialize() first."
|
||||
)
|
||||
return _auto_claude_dir
|
||||
|
||||
|
||||
def get_specs_dir() -> Path:
|
||||
"""Get the specs directory for the active project."""
|
||||
return get_auto_claude_dir() / "specs"
|
||||
|
||||
|
||||
def get_project_index() -> dict:
|
||||
"""Load and return the project index if available."""
|
||||
index_path = get_auto_claude_dir() / "project_index.json"
|
||||
if not index_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load project index: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
"""Check if the project has been initialized with .auto-claude."""
|
||||
try:
|
||||
ac_dir = get_auto_claude_dir()
|
||||
return ac_dir.is_dir()
|
||||
except RuntimeError:
|
||||
return False
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Long-Running Operation Tracker
|
||||
===============================
|
||||
|
||||
Tracks async operations (spec creation, builds, QA, etc.) so MCP clients
|
||||
can poll for progress. Tools that start long-running work return an
|
||||
operation_id immediately; clients poll operation_get_status() for updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OperationStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Operation:
|
||||
"""Represents a long-running operation."""
|
||||
|
||||
id: str
|
||||
type: str # e.g. "spec_create", "build", "qa_review"
|
||||
status: OperationStatus = OperationStatus.PENDING
|
||||
progress: int = 0 # 0-100
|
||||
message: str = ""
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
_task: asyncio.Task | None = field(default=None, repr=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for MCP response."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"status": self.status.value,
|
||||
"progress": self.progress,
|
||||
"message": self.message,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"elapsed_seconds": round(time.time() - self.created_at, 1),
|
||||
}
|
||||
|
||||
|
||||
class OperationTracker:
|
||||
"""Manages the lifecycle of long-running operations."""
|
||||
|
||||
def __init__(self, max_completed: int = 100):
|
||||
self._operations: dict[str, Operation] = {}
|
||||
self._max_completed = max_completed
|
||||
|
||||
def create(self, operation_type: str, message: str = "") -> Operation:
|
||||
"""Create a new operation and return it."""
|
||||
op = Operation(
|
||||
id=str(uuid.uuid4()),
|
||||
type=operation_type,
|
||||
status=OperationStatus.PENDING,
|
||||
message=message or f"Starting {operation_type}...",
|
||||
)
|
||||
self._operations[op.id] = op
|
||||
self._cleanup_old()
|
||||
return op
|
||||
|
||||
def get(self, operation_id: str) -> Operation | None:
|
||||
"""Get an operation by ID."""
|
||||
return self._operations.get(operation_id)
|
||||
|
||||
def update(
|
||||
self,
|
||||
operation_id: str,
|
||||
*,
|
||||
status: OperationStatus | None = None,
|
||||
progress: int | None = None,
|
||||
message: str | None = None,
|
||||
result: Any = None,
|
||||
error: str | None = None,
|
||||
) -> Operation | None:
|
||||
"""Update an operation's state."""
|
||||
op = self._operations.get(operation_id)
|
||||
if op is None:
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
op.status = status
|
||||
if progress is not None:
|
||||
op.progress = max(0, min(100, progress))
|
||||
if message is not None:
|
||||
op.message = message
|
||||
if result is not None:
|
||||
op.result = result
|
||||
if error is not None:
|
||||
op.error = error
|
||||
op.updated_at = time.time()
|
||||
return op
|
||||
|
||||
def cancel(self, operation_id: str) -> bool:
|
||||
"""Cancel a running operation."""
|
||||
op = self._operations.get(operation_id)
|
||||
if op is None:
|
||||
return False
|
||||
if op.status in (OperationStatus.COMPLETED, OperationStatus.FAILED):
|
||||
return False
|
||||
|
||||
# Cancel the asyncio task if it exists
|
||||
if op._task and not op._task.done():
|
||||
op._task.cancel()
|
||||
|
||||
op.status = OperationStatus.CANCELLED
|
||||
op.message = "Operation cancelled by user"
|
||||
op.updated_at = time.time()
|
||||
return True
|
||||
|
||||
def list_active(self) -> list[Operation]:
|
||||
"""List all active (non-terminal) operations."""
|
||||
return [
|
||||
op
|
||||
for op in self._operations.values()
|
||||
if op.status in (OperationStatus.PENDING, OperationStatus.RUNNING)
|
||||
]
|
||||
|
||||
def _cleanup_old(self) -> None:
|
||||
"""Remove old completed operations to prevent memory growth."""
|
||||
completed = [
|
||||
op
|
||||
for op in self._operations.values()
|
||||
if op.status
|
||||
in (
|
||||
OperationStatus.COMPLETED,
|
||||
OperationStatus.FAILED,
|
||||
OperationStatus.CANCELLED,
|
||||
)
|
||||
]
|
||||
if len(completed) > self._max_completed:
|
||||
# Sort by created_at, remove oldest
|
||||
completed.sort(key=lambda o: o.created_at)
|
||||
for op in completed[: len(completed) - self._max_completed]:
|
||||
del self._operations[op.id]
|
||||
|
||||
|
||||
# Global singleton
|
||||
tracker = OperationTracker()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Auto Claude MCP Server
|
||||
======================
|
||||
|
||||
FastMCP server instance with all tool registrations.
|
||||
Tools are organized into modules under mcp_server/tools/.
|
||||
Each module's register() function adds tools to the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create the FastMCP server instance
|
||||
mcp = FastMCP(
|
||||
"Auto Claude",
|
||||
instructions=(
|
||||
"Auto Claude is an autonomous multi-agent coding framework. "
|
||||
"Use these tools to manage tasks, create specs, run builds, "
|
||||
"perform QA reviews, manage workspaces, and more. "
|
||||
"Long-running operations return an operation_id - "
|
||||
"poll with operation_get_status() for progress."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def register_all_tools() -> None:
|
||||
"""Register all tool modules with the MCP server.
|
||||
|
||||
Each tool module defines functions decorated with @mcp.tool()
|
||||
that are imported here to trigger registration.
|
||||
"""
|
||||
# Phase 1: Project & Task management
|
||||
# Phase 2: Core autonomous pipeline
|
||||
# Phase 3: Feature tools
|
||||
# Operations management (poll long-running ops)
|
||||
from mcp_server.tools import (
|
||||
execution, # noqa: F401
|
||||
github, # noqa: F401
|
||||
ideation, # noqa: F401
|
||||
insights, # noqa: F401
|
||||
memory, # noqa: F401
|
||||
ops, # noqa: F401
|
||||
project, # noqa: F401
|
||||
qa, # noqa: F401
|
||||
roadmap, # noqa: F401
|
||||
specs, # noqa: F401
|
||||
tasks, # noqa: F401
|
||||
workspace, # noqa: F401
|
||||
)
|
||||
|
||||
logger.info("All MCP tools registered successfully")
|
||||
@@ -0,0 +1 @@
|
||||
"""Service layer - thin adapters wrapping existing backend modules."""
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Execution Service
|
||||
==================
|
||||
|
||||
Service layer for spawning and managing build processes.
|
||||
Wraps the run.py subprocess and parses task events from stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches core/task_event.py
|
||||
TASK_EVENT_PREFIX = "__TASK_EVENT__:"
|
||||
|
||||
# Module-level singleton
|
||||
_instance: ExecutionService | None = None
|
||||
|
||||
|
||||
def get_execution_service(project_dir: Path) -> ExecutionService:
|
||||
"""Return a lazily-created singleton ExecutionService.
|
||||
|
||||
If project_dir changes (e.g. user switches projects), a new instance
|
||||
is created so in-memory state matches the active project.
|
||||
"""
|
||||
global _instance
|
||||
if _instance is None or _instance.project_dir != project_dir:
|
||||
_instance = ExecutionService(project_dir)
|
||||
return _instance
|
||||
|
||||
|
||||
class ExecutionService:
|
||||
"""Manages build execution as a subprocess of run.py."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self._processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
self._logs: dict[str, list[str]] = {}
|
||||
self._events: dict[str, list[dict]] = {}
|
||||
|
||||
async def start_build(
|
||||
self,
|
||||
spec_id: str,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a build subprocess for the given spec.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
model: Model shorthand
|
||||
thinking_level: Thinking level
|
||||
|
||||
Returns:
|
||||
The subprocess handle
|
||||
|
||||
Raises:
|
||||
RuntimeError: If a build is already running for this spec
|
||||
"""
|
||||
if spec_id in self._processes:
|
||||
proc = self._processes[spec_id]
|
||||
if proc.returncode is None:
|
||||
raise RuntimeError(
|
||||
f"Build already running for spec '{spec_id}'. "
|
||||
"Stop it first with build_stop()."
|
||||
)
|
||||
|
||||
backend_dir = Path(__file__).parent.parent.parent # apps/backend/
|
||||
run_py = backend_dir / "run.py"
|
||||
if not run_py.exists():
|
||||
raise FileNotFoundError(f"run.py not found at {run_py}")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(run_py),
|
||||
"--spec",
|
||||
spec_id,
|
||||
"--project-dir",
|
||||
str(self.project_dir),
|
||||
"--model",
|
||||
model,
|
||||
"--thinking",
|
||||
thinking_level,
|
||||
]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(backend_dir),
|
||||
)
|
||||
|
||||
self._processes[spec_id] = proc
|
||||
self._logs[spec_id] = []
|
||||
self._events[spec_id] = []
|
||||
|
||||
# Start background reader for stdout
|
||||
asyncio.create_task(self._read_output(spec_id, proc))
|
||||
|
||||
return proc
|
||||
|
||||
async def _read_output(
|
||||
self, spec_id: str, proc: asyncio.subprocess.Process
|
||||
) -> None:
|
||||
"""Read stdout from the build process, parsing task events.
|
||||
|
||||
Args:
|
||||
spec_id: The spec being built
|
||||
proc: The subprocess to read from
|
||||
"""
|
||||
if proc.stdout is None:
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
line_bytes = await proc.stdout.readline()
|
||||
if not line_bytes:
|
||||
break
|
||||
line = line_bytes.decode("utf-8", errors="replace").rstrip("\n")
|
||||
|
||||
# Store the log line
|
||||
log_list = self._logs.get(spec_id)
|
||||
if log_list is not None:
|
||||
log_list.append(line)
|
||||
# Cap stored logs to prevent unbounded growth
|
||||
if len(log_list) > 5000:
|
||||
del log_list[:1000]
|
||||
|
||||
# Parse task events
|
||||
event = self.parse_event(line)
|
||||
if event is not None:
|
||||
events_list = self._events.get(spec_id)
|
||||
if events_list is not None:
|
||||
events_list.append(event)
|
||||
except Exception as e:
|
||||
logger.warning("Error reading build output for %s: %s", spec_id, e)
|
||||
|
||||
def parse_event(self, line: str) -> dict | None:
|
||||
"""Parse a task event line from build stdout.
|
||||
|
||||
Args:
|
||||
line: A line of stdout output
|
||||
|
||||
Returns:
|
||||
Parsed event dict or None if not an event line
|
||||
"""
|
||||
if not line.startswith(TASK_EVENT_PREFIX):
|
||||
return None
|
||||
try:
|
||||
return json.loads(line[len(TASK_EVENT_PREFIX) :])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def stop_build(self, spec_id: str) -> dict:
|
||||
"""Stop a running build process.
|
||||
|
||||
Args:
|
||||
spec_id: The spec being built
|
||||
|
||||
Returns:
|
||||
Status dict
|
||||
"""
|
||||
proc = self._processes.get(spec_id)
|
||||
if proc is None:
|
||||
return {"success": False, "error": f"No build found for spec '{spec_id}'"}
|
||||
|
||||
if proc.returncode is not None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Build for '{spec_id}' already finished (exit code {proc.returncode})",
|
||||
}
|
||||
|
||||
try:
|
||||
proc.terminate()
|
||||
return {"success": True, "message": f"Build for '{spec_id}' terminated"}
|
||||
except ProcessLookupError:
|
||||
return {"success": False, "error": "Process already exited"}
|
||||
|
||||
def get_progress(self, spec_id: str) -> dict:
|
||||
"""Get progress of a build by inspecting events and process state.
|
||||
|
||||
Args:
|
||||
spec_id: The spec being built
|
||||
|
||||
Returns:
|
||||
Dict with status, events, and process info
|
||||
"""
|
||||
proc = self._processes.get(spec_id)
|
||||
events = self._events.get(spec_id, [])
|
||||
|
||||
if proc is None:
|
||||
# Check if there's a completed implementation plan on disk
|
||||
return self._get_disk_progress(spec_id)
|
||||
|
||||
is_running = proc.returncode is None
|
||||
latest_event = events[-1] if events else None
|
||||
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"running": is_running,
|
||||
"exit_code": proc.returncode,
|
||||
"event_count": len(events),
|
||||
"latest_event": latest_event,
|
||||
"log_lines": len(self._logs.get(spec_id, [])),
|
||||
}
|
||||
|
||||
def get_logs(self, spec_id: str, tail: int = 50) -> dict:
|
||||
"""Get recent build logs.
|
||||
|
||||
Args:
|
||||
spec_id: The spec being built
|
||||
tail: Number of recent lines to return
|
||||
|
||||
Returns:
|
||||
Dict with log lines
|
||||
"""
|
||||
logs = self._logs.get(spec_id, [])
|
||||
if not logs:
|
||||
# Try to find logs on disk
|
||||
return self._get_disk_logs(spec_id, tail)
|
||||
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"total_lines": len(logs),
|
||||
"lines": logs[-tail:],
|
||||
}
|
||||
|
||||
def _get_disk_progress(self, spec_id: str) -> dict:
|
||||
"""Check on-disk state for build progress when no process is tracked.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
|
||||
Returns:
|
||||
Progress dict from disk state
|
||||
"""
|
||||
specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
spec_dir = self._resolve_spec_dir(specs_dir, spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found"}
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"running": False,
|
||||
"status": "no_plan",
|
||||
"message": "No implementation plan found. Create a spec first.",
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"running": False,
|
||||
"status": "error",
|
||||
"message": "Could not read implementation plan",
|
||||
}
|
||||
|
||||
subtasks = plan.get("subtasks", [])
|
||||
completed = sum(1 for s in subtasks if s.get("status") == "completed")
|
||||
total = len(subtasks)
|
||||
|
||||
qa_signoff = plan.get("qa_signoff")
|
||||
if qa_signoff and qa_signoff.get("status") == "approved":
|
||||
status = "qa_approved"
|
||||
elif qa_signoff and qa_signoff.get("status") == "rejected":
|
||||
status = "qa_rejected"
|
||||
elif completed == total and total > 0:
|
||||
status = "build_complete"
|
||||
elif completed > 0:
|
||||
status = "building"
|
||||
else:
|
||||
status = "not_started"
|
||||
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"running": False,
|
||||
"status": status,
|
||||
"subtasks_completed": completed,
|
||||
"subtasks_total": total,
|
||||
"qa_signoff": qa_signoff,
|
||||
}
|
||||
|
||||
def _get_disk_logs(self, spec_id: str, tail: int) -> dict:
|
||||
"""Try to find build logs on disk.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Dict with log content
|
||||
"""
|
||||
specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
spec_dir = self._resolve_spec_dir(specs_dir, spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found", "lines": []}
|
||||
|
||||
# Check for task log file
|
||||
log_file = spec_dir / "task_log.jsonl"
|
||||
if not log_file.exists():
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"lines": [],
|
||||
"message": "No build logs found",
|
||||
}
|
||||
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"total_lines": len(lines),
|
||||
"lines": lines[-tail:],
|
||||
}
|
||||
except OSError as e:
|
||||
return {"error": str(e), "lines": []}
|
||||
|
||||
def _resolve_spec_dir(self, specs_dir: Path, spec_id: str) -> Path | None:
|
||||
"""Resolve spec_id to directory with prefix matching."""
|
||||
exact = specs_dir / spec_id
|
||||
if exact.is_dir():
|
||||
return exact
|
||||
|
||||
if specs_dir.is_dir():
|
||||
for item in specs_dir.iterdir():
|
||||
if item.is_dir() and item.name.startswith(spec_id):
|
||||
return item
|
||||
return None
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
GitHub Service
|
||||
==============
|
||||
|
||||
Wraps the backend GitHubOrchestrator for MCP tool access.
|
||||
Handles repo detection, config creation, and result serialization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitHubService:
|
||||
"""Service layer for GitHub automation features."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.github_dir = project_dir / ".auto-claude" / "github"
|
||||
|
||||
def _detect_repo(self) -> str | None:
|
||||
"""Detect owner/repo from git remote origin."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.project_dir),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
url = result.stdout.strip()
|
||||
# Handle SSH: git@github.com:owner/repo.git
|
||||
if url.startswith("git@"):
|
||||
parts = url.split(":")[-1]
|
||||
return parts.removesuffix(".git")
|
||||
# Handle HTTPS: https://github.com/owner/repo.git
|
||||
if "github.com" in url:
|
||||
parts = url.split("github.com/")[-1]
|
||||
return parts.removesuffix(".git")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to detect repo from git remote: %s", e)
|
||||
return None
|
||||
|
||||
def _get_repo(self, repo: str | None) -> str:
|
||||
"""Get repo string, falling back to auto-detection."""
|
||||
if repo:
|
||||
return repo
|
||||
detected = self._detect_repo()
|
||||
if not detected:
|
||||
raise ValueError(
|
||||
"Could not detect repository. Provide 'repo' parameter "
|
||||
"in owner/repo format, or ensure a GitHub remote is configured."
|
||||
)
|
||||
return detected
|
||||
|
||||
def _create_config(self, repo: str, model: str = "sonnet"):
|
||||
"""Create a GitHubRunnerConfig with sensible defaults."""
|
||||
# Get GitHub token from environment
|
||||
import os
|
||||
|
||||
from runners.github.models import GitHubRunnerConfig
|
||||
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
if not token:
|
||||
# Try gh CLI auth token
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "auth", "token"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
token = result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return GitHubRunnerConfig(
|
||||
token=token,
|
||||
repo=repo,
|
||||
model=model,
|
||||
thinking_level="medium",
|
||||
pr_review_enabled=True,
|
||||
triage_enabled=True,
|
||||
)
|
||||
|
||||
async def review_pr(
|
||||
self, pr_number: int, repo: str | None = None, model: str = "sonnet"
|
||||
) -> dict:
|
||||
"""Review a pull request with AI."""
|
||||
try:
|
||||
from runners.github.orchestrator import GitHubOrchestrator
|
||||
|
||||
resolved_repo = self._get_repo(repo)
|
||||
config = self._create_config(resolved_repo, model)
|
||||
orchestrator = GitHubOrchestrator(
|
||||
project_dir=self.project_dir, config=config
|
||||
)
|
||||
result = await orchestrator.review_pr(pr_number)
|
||||
return {"success": True, "data": result.to_dict()}
|
||||
except ImportError:
|
||||
return {"error": "GitHub runner module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def list_issues(
|
||||
self, state: str = "open", limit: int = 30, repo: str | None = None
|
||||
) -> dict:
|
||||
"""List GitHub issues using gh CLI."""
|
||||
try:
|
||||
resolved_repo = self._get_repo(repo)
|
||||
cmd = [
|
||||
"gh",
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
resolved_repo,
|
||||
"--state",
|
||||
state,
|
||||
"--limit",
|
||||
str(limit),
|
||||
"--json",
|
||||
"number,title,state,labels,author,createdAt,updatedAt",
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.project_dir),
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {"error": f"gh CLI failed: {result.stderr.strip()}"}
|
||||
issues = json.loads(result.stdout)
|
||||
return {"success": True, "issues": issues, "count": len(issues)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def auto_fix_issue(self, issue_number: int, repo: str | None = None) -> dict:
|
||||
"""Auto-fix a GitHub issue."""
|
||||
try:
|
||||
from runners.github.orchestrator import GitHubOrchestrator
|
||||
|
||||
resolved_repo = self._get_repo(repo)
|
||||
config = self._create_config(resolved_repo)
|
||||
config.auto_fix_enabled = True
|
||||
orchestrator = GitHubOrchestrator(
|
||||
project_dir=self.project_dir, config=config
|
||||
)
|
||||
state = await orchestrator.auto_fix_issue(issue_number)
|
||||
return {"success": True, "data": state.to_dict()}
|
||||
except ImportError:
|
||||
return {"error": "GitHub runner module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def get_review(self, pr_number: int) -> dict:
|
||||
"""Get the most recent review result for a PR."""
|
||||
try:
|
||||
from runners.github.models import PRReviewResult
|
||||
|
||||
result = PRReviewResult.load(self.github_dir, pr_number)
|
||||
if result is None:
|
||||
return {"error": f"No review found for PR #{pr_number}"}
|
||||
return {"success": True, "data": result.to_dict()}
|
||||
except ImportError:
|
||||
return {"error": "GitHub runner module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def triage_issues(
|
||||
self, issue_numbers: list[int], repo: str | None = None
|
||||
) -> dict:
|
||||
"""Triage and classify GitHub issues."""
|
||||
try:
|
||||
from runners.github.orchestrator import GitHubOrchestrator
|
||||
|
||||
resolved_repo = self._get_repo(repo)
|
||||
config = self._create_config(resolved_repo)
|
||||
config.triage_enabled = True
|
||||
orchestrator = GitHubOrchestrator(
|
||||
project_dir=self.project_dir, config=config
|
||||
)
|
||||
results = await orchestrator.triage_issues(issue_numbers=issue_numbers)
|
||||
return {
|
||||
"success": True,
|
||||
"data": [r.to_dict() for r in results],
|
||||
"count": len(results),
|
||||
}
|
||||
except ImportError:
|
||||
return {"error": "GitHub runner module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Ideation Service
|
||||
=================
|
||||
|
||||
Wraps the backend IdeationOrchestrator for MCP tool access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid ideation types
|
||||
VALID_IDEATION_TYPES = [
|
||||
"low_hanging_fruit",
|
||||
"ui_ux_improvements",
|
||||
"high_value_features",
|
||||
]
|
||||
|
||||
|
||||
class IdeationService:
|
||||
"""Service layer for AI-powered ideation generation."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.ideation_dir = project_dir / ".auto-claude" / "ideation"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
types: list[str] | None = None,
|
||||
refresh: bool = False,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
) -> dict:
|
||||
"""Generate ideas for project improvements."""
|
||||
try:
|
||||
from ideation import IdeationOrchestrator
|
||||
|
||||
# Validate types
|
||||
enabled_types = types or VALID_IDEATION_TYPES
|
||||
invalid = [t for t in enabled_types if t not in VALID_IDEATION_TYPES]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Invalid ideation types: {invalid}. "
|
||||
f"Valid types: {VALID_IDEATION_TYPES}"
|
||||
}
|
||||
|
||||
orchestrator = IdeationOrchestrator(
|
||||
project_dir=self.project_dir,
|
||||
enabled_types=enabled_types,
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
refresh=refresh,
|
||||
)
|
||||
success = await orchestrator.run()
|
||||
|
||||
if success:
|
||||
return self.get_ideation()
|
||||
return {"error": "Ideation generation failed. Check logs for details."}
|
||||
except ImportError:
|
||||
return {"error": "Ideation module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def get_ideation(self) -> dict:
|
||||
"""Get previously generated ideation results from disk."""
|
||||
ideation_file = self.ideation_dir / "ideation.json"
|
||||
if not ideation_file.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"data": None,
|
||||
"message": "No ideation data yet. Use ideation_generate first.",
|
||||
}
|
||||
try:
|
||||
with open(ideation_file, encoding="utf-8") as f:
|
||||
ideation = json.load(f)
|
||||
return {"success": True, "data": ideation}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return {"error": f"Failed to load ideation data: {e}"}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Insights Service
|
||||
=================
|
||||
|
||||
Wraps the backend InsightsRunner for MCP tool access.
|
||||
Captures stdout output since run_with_sdk prints to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InsightsService:
|
||||
"""Service layer for codebase insights / AI chat."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
|
||||
async def ask(
|
||||
self,
|
||||
question: str,
|
||||
history: list | None = None,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
) -> dict:
|
||||
"""Ask an AI question about the codebase.
|
||||
|
||||
IMPORTANT: run_with_sdk prints to stdout, so we capture it.
|
||||
"""
|
||||
try:
|
||||
from runners.insights_runner import run_with_sdk
|
||||
|
||||
history = history or []
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
await run_with_sdk(
|
||||
project_dir=str(self.project_dir),
|
||||
message=question,
|
||||
history=history,
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
|
||||
output = captured.getvalue()
|
||||
|
||||
# Parse out any task suggestions from the output
|
||||
task_suggestions = []
|
||||
response_lines = []
|
||||
for line in output.split("\n"):
|
||||
if line.startswith("__TASK_SUGGESTION__:"):
|
||||
try:
|
||||
suggestion_json = line.split("__TASK_SUGGESTION__:", 1)[1]
|
||||
task_suggestions.append(json.loads(suggestion_json))
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
pass
|
||||
elif line.startswith("__TOOL_START__:") or line.startswith(
|
||||
"__TOOL_END__:"
|
||||
):
|
||||
# Skip tool markers - they're for the Electron UI
|
||||
pass
|
||||
else:
|
||||
response_lines.append(line)
|
||||
|
||||
response_text = "\n".join(response_lines).strip()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"response": response_text,
|
||||
"task_suggestions": task_suggestions,
|
||||
}
|
||||
except ImportError:
|
||||
return {"error": "Insights runner module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def suggest_tasks(self) -> dict:
|
||||
"""Get AI-suggested tasks based on project analysis.
|
||||
|
||||
Reads the most recent ideation/insights data if available.
|
||||
"""
|
||||
try:
|
||||
ideation_file = (
|
||||
self.project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
)
|
||||
if ideation_file.exists():
|
||||
with open(ideation_file, encoding="utf-8") as f:
|
||||
ideation = json.load(f)
|
||||
ideas = ideation.get("ideas", [])
|
||||
# Convert top ideas to task suggestions
|
||||
suggestions = []
|
||||
for idea in ideas[:10]:
|
||||
suggestions.append(
|
||||
{
|
||||
"title": idea.get("title", ""),
|
||||
"description": idea.get("description", ""),
|
||||
"category": idea.get("type", "feature"),
|
||||
"impact": idea.get("impact", "medium"),
|
||||
"effort": idea.get("effort", "medium"),
|
||||
}
|
||||
)
|
||||
return {"success": True, "suggestions": suggestions}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"suggestions": [],
|
||||
"message": "No ideation data available. Run ideation_generate first.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Memory Service
|
||||
===============
|
||||
|
||||
Wraps the Graphiti memory system for MCP tool access.
|
||||
Gracefully handles the case where Graphiti is not enabled/configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level singleton
|
||||
_instance: MemoryService | None = None
|
||||
|
||||
|
||||
def get_memory_service(project_dir: Path) -> MemoryService:
|
||||
"""Return a lazily-created singleton MemoryService.
|
||||
|
||||
Preserves the cached Graphiti connection across tool calls.
|
||||
If project_dir changes, a new instance is created.
|
||||
"""
|
||||
global _instance
|
||||
if _instance is None or _instance.project_dir != project_dir:
|
||||
_instance = MemoryService(project_dir)
|
||||
return _instance
|
||||
|
||||
|
||||
def _is_graphiti_enabled() -> bool:
|
||||
"""Check if Graphiti memory is enabled via environment variable."""
|
||||
return os.environ.get("GRAPHITI_ENABLED", "").lower() in ("true", "1")
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""Service layer for Graphiti-based semantic memory."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self._memory = None
|
||||
|
||||
def _get_disabled_message(self) -> dict:
|
||||
"""Return a helpful error when Graphiti is not enabled."""
|
||||
return {
|
||||
"error": "Graphiti memory is not enabled. "
|
||||
"Set GRAPHITI_ENABLED=true in your .env file and configure "
|
||||
"the required provider settings (LLM and embedder). "
|
||||
"See the project documentation for setup instructions."
|
||||
}
|
||||
|
||||
async def _get_memory(self):
|
||||
"""Lazily initialize and return a GraphitiMemory instance."""
|
||||
if self._memory is not None:
|
||||
return self._memory
|
||||
|
||||
if not _is_graphiti_enabled():
|
||||
return None
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import (
|
||||
GraphitiMemory,
|
||||
GroupIdMode,
|
||||
)
|
||||
|
||||
# Use a dummy spec_dir since we're in project-wide mode
|
||||
spec_dir = self.project_dir / ".auto-claude" / "mcp_memory"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
memory = GraphitiMemory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=self.project_dir,
|
||||
group_id_mode=GroupIdMode.PROJECT,
|
||||
)
|
||||
|
||||
if not await memory.initialize():
|
||||
logger.warning("Failed to initialize Graphiti memory")
|
||||
return None
|
||||
|
||||
self._memory = memory
|
||||
return memory
|
||||
except ImportError:
|
||||
logger.warning("Graphiti modules not available")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create Graphiti memory: %s", e)
|
||||
return None
|
||||
|
||||
async def search(self, query: str, limit: int = 10) -> dict:
|
||||
"""Search the project's semantic memory."""
|
||||
if not _is_graphiti_enabled():
|
||||
return self._get_disabled_message()
|
||||
|
||||
try:
|
||||
memory = await self._get_memory()
|
||||
if memory is None:
|
||||
return {"error": "Could not initialize Graphiti memory"}
|
||||
|
||||
results = await memory.get_relevant_context(
|
||||
query=query,
|
||||
num_results=limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Memory search failed: {e}"}
|
||||
|
||||
async def add_episode(self, content: str, source: str = "mcp") -> dict:
|
||||
"""Add a new episode/fact to the project's memory."""
|
||||
if not _is_graphiti_enabled():
|
||||
return self._get_disabled_message()
|
||||
|
||||
try:
|
||||
memory = await self._get_memory()
|
||||
if memory is None:
|
||||
return {"error": "Could not initialize Graphiti memory"}
|
||||
|
||||
success = await memory.save_session_insights(
|
||||
session_num=0,
|
||||
insights={
|
||||
"content": content,
|
||||
"source": source,
|
||||
"type": "mcp_episode",
|
||||
},
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Episode added to memory"}
|
||||
return {"error": "Failed to save episode to memory"}
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to add episode: {e}"}
|
||||
|
||||
async def get_recent(self, limit: int = 10) -> dict:
|
||||
"""Get recent memory entries."""
|
||||
if not _is_graphiti_enabled():
|
||||
return self._get_disabled_message()
|
||||
|
||||
try:
|
||||
memory = await self._get_memory()
|
||||
if memory is None:
|
||||
return {"error": "Could not initialize Graphiti memory"}
|
||||
|
||||
# Use a broad search to get recent entries
|
||||
results = await memory.get_relevant_context(
|
||||
query="recent project activity and insights",
|
||||
num_results=limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get recent memory: {e}"}
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
QA Service
|
||||
===========
|
||||
|
||||
Service layer wrapping the backend QA reviewer for MCP tool consumption.
|
||||
Handles client creation, stdout isolation, and error management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QAService:
|
||||
"""Wraps QA review and approval operations for MCP server use."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
|
||||
async def start_review(
|
||||
self,
|
||||
spec_id: str,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
max_iterations: int = 3,
|
||||
) -> dict:
|
||||
"""Run a QA review session for a completed build.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
model: Model shorthand
|
||||
thinking_level: Thinking level
|
||||
max_iterations: Maximum QA loop iterations
|
||||
|
||||
Returns:
|
||||
Dict with review outcome (approved/rejected/error)
|
||||
"""
|
||||
spec_dir = self._resolve_spec_dir(spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found"}
|
||||
|
||||
# Verify the build is complete before starting QA
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"error": "No implementation plan found. Build the spec first.",
|
||||
}
|
||||
|
||||
try:
|
||||
from core.client import create_client
|
||||
from qa.reviewer import run_qa_agent_session
|
||||
except ImportError as e:
|
||||
logger.error("Failed to import QA modules: %s", e)
|
||||
return {"error": f"Backend module not available: {e}"}
|
||||
|
||||
try:
|
||||
# Determine QA session number from existing state
|
||||
qa_session = self._get_next_qa_session(spec_dir)
|
||||
|
||||
# Create a Claude SDK client for the QA agent
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
phase="qa_reviewer",
|
||||
)
|
||||
|
||||
status, response_text, error_info = await run_qa_agent_session(
|
||||
client=client,
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=spec_dir,
|
||||
qa_session=qa_session,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"status": status,
|
||||
"qa_session": qa_session,
|
||||
"response_preview": response_text[:1000] if response_text else "",
|
||||
"error_info": error_info if error_info else None,
|
||||
"output": captured.getvalue()[-1000:] if captured.getvalue() else "",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("QA review failed for %s", spec_id)
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def get_report(self, spec_id: str) -> dict:
|
||||
"""Get the QA report for a spec.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
|
||||
Returns:
|
||||
Dict with QA report content and status
|
||||
"""
|
||||
spec_dir = self._resolve_spec_dir(spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found"}
|
||||
|
||||
result: dict = {"spec_id": spec_id}
|
||||
|
||||
# Read qa_report.md
|
||||
qa_report = spec_dir / "qa_report.md"
|
||||
if qa_report.exists():
|
||||
try:
|
||||
result["report"] = qa_report.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
result["report_error"] = str(e)
|
||||
|
||||
# Read QA fix request if present
|
||||
fix_request = spec_dir / "QA_FIX_REQUEST.md"
|
||||
if fix_request.exists():
|
||||
try:
|
||||
result["fix_request"] = fix_request.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
result["fix_request_error"] = str(e)
|
||||
|
||||
# Read qa_signoff from implementation plan
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
qa_signoff = plan.get("qa_signoff")
|
||||
if qa_signoff:
|
||||
result["qa_signoff"] = qa_signoff
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
if "report" not in result and "qa_signoff" not in result:
|
||||
result["message"] = "No QA report found. Run QA review first."
|
||||
|
||||
return result
|
||||
|
||||
def approve(self, spec_id: str) -> dict:
|
||||
"""Manually approve a spec's QA status.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
|
||||
Returns:
|
||||
Dict with approval result
|
||||
"""
|
||||
spec_dir = self._resolve_spec_dir(spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found"}
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {"error": "No implementation plan found"}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return {"error": f"Could not read implementation plan: {e}"}
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
plan["qa_signoff"] = {
|
||||
"status": "approved",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"qa_session": plan.get("qa_signoff", {}).get("qa_session", 0),
|
||||
"verified_by": "manual_approval",
|
||||
"note": "Manually approved via MCP tool",
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, "w", encoding="utf-8") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
except OSError as e:
|
||||
return {"error": f"Could not write implementation plan: {e}"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"spec_id": spec_id,
|
||||
"message": "Spec manually approved",
|
||||
}
|
||||
|
||||
def _get_next_qa_session(self, spec_dir: Path) -> int:
|
||||
"""Get the next QA session number.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Next session number (1-based)
|
||||
"""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return 1
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
qa_signoff = plan.get("qa_signoff", {})
|
||||
current = qa_signoff.get("qa_session", 0)
|
||||
return current + 1
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return 1
|
||||
|
||||
def _resolve_spec_dir(self, spec_id: str) -> Path | None:
|
||||
"""Resolve spec_id to its directory path.
|
||||
|
||||
Args:
|
||||
spec_id: Full or prefix spec identifier
|
||||
|
||||
Returns:
|
||||
Path to spec directory or None
|
||||
"""
|
||||
specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
|
||||
# Direct match
|
||||
exact = specs_dir / spec_id
|
||||
if exact.is_dir():
|
||||
return exact
|
||||
|
||||
# Prefix match
|
||||
if specs_dir.is_dir():
|
||||
for item in specs_dir.iterdir():
|
||||
if item.is_dir() and item.name.startswith(spec_id):
|
||||
return item
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Roadmap Service
|
||||
================
|
||||
|
||||
Wraps the backend RoadmapOrchestrator for MCP tool access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RoadmapService:
|
||||
"""Service layer for roadmap generation features."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.roadmap_dir = project_dir / ".auto-claude" / "roadmap"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
refresh: bool = False,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
) -> dict:
|
||||
"""Generate a strategic roadmap for the project."""
|
||||
try:
|
||||
from runners.roadmap.orchestrator import RoadmapOrchestrator
|
||||
|
||||
orchestrator = RoadmapOrchestrator(
|
||||
project_dir=self.project_dir,
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
refresh=refresh,
|
||||
)
|
||||
success = await orchestrator.run()
|
||||
|
||||
if success:
|
||||
# Load and return the generated roadmap
|
||||
return self.get_roadmap()
|
||||
return {"error": "Roadmap generation failed. Check logs for details."}
|
||||
except ImportError:
|
||||
return {"error": "Roadmap runner module not available"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def get_roadmap(self) -> dict:
|
||||
"""Get the current roadmap data from disk."""
|
||||
roadmap_file = self.roadmap_dir / "roadmap.json"
|
||||
if not roadmap_file.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"data": None,
|
||||
"message": "No roadmap generated yet. Use roadmap_generate first.",
|
||||
}
|
||||
try:
|
||||
with open(roadmap_file, encoding="utf-8") as f:
|
||||
roadmap = json.load(f)
|
||||
return {"success": True, "data": roadmap}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return {"error": f"Failed to load roadmap: {e}"}
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Spec Service
|
||||
=============
|
||||
|
||||
Service layer wrapping the backend SpecOrchestrator for MCP tool consumption.
|
||||
Handles stdout isolation and error management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpecService:
|
||||
"""Wraps backend spec creation pipeline for MCP server use."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
|
||||
async def create_spec(
|
||||
self,
|
||||
task_description: str,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
complexity_override: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a spec using the SpecOrchestrator.
|
||||
|
||||
Redirects stdout to prevent protocol corruption when running
|
||||
under stdio transport.
|
||||
|
||||
Args:
|
||||
task_description: Description of the task to spec out
|
||||
model: Model shorthand (sonnet, opus, etc.)
|
||||
thinking_level: Thinking level (low, medium, high)
|
||||
complexity_override: Force a specific complexity level
|
||||
|
||||
Returns:
|
||||
Dict with success status, spec_dir, spec_id, and any captured output
|
||||
"""
|
||||
try:
|
||||
from spec.pipeline.orchestrator import SpecOrchestrator
|
||||
except ImportError as e:
|
||||
logger.error("Failed to import SpecOrchestrator: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Backend module not available: {e}",
|
||||
}
|
||||
|
||||
try:
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
orchestrator = SpecOrchestrator(
|
||||
project_dir=self.project_dir,
|
||||
task_description=task_description,
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
complexity_override=complexity_override,
|
||||
use_ai_assessment=True,
|
||||
)
|
||||
# Run non-interactively with auto-approve for MCP
|
||||
success = await orchestrator.run(interactive=False, auto_approve=True)
|
||||
|
||||
spec_dir = orchestrator.spec_dir
|
||||
return {
|
||||
"success": success,
|
||||
"spec_dir": str(spec_dir),
|
||||
"spec_id": spec_dir.name,
|
||||
"output": captured.getvalue()[-2000:] if captured.getvalue() else "",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Spec creation failed")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def get_spec_status(self, spec_id: str) -> dict:
|
||||
"""Get the status of a spec by checking which phase files exist.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name (e.g. '001-my-feature')
|
||||
|
||||
Returns:
|
||||
Dict describing which phases are complete and current state
|
||||
"""
|
||||
specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
spec_dir = self._resolve_spec_dir(specs_dir, spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found"}
|
||||
|
||||
phases = {
|
||||
"discovery": (spec_dir / "discovery.md").exists(),
|
||||
"requirements": (spec_dir / "requirements.json").exists(),
|
||||
"complexity_assessment": (spec_dir / "complexity_assessment.json").exists(),
|
||||
"spec": (spec_dir / "spec.md").exists(),
|
||||
"implementation_plan": (spec_dir / "implementation_plan.json").exists(),
|
||||
}
|
||||
|
||||
# Determine overall status
|
||||
if phases["implementation_plan"]:
|
||||
plan = self._load_json(spec_dir / "implementation_plan.json")
|
||||
qa_signoff = plan.get("qa_signoff") if plan else None
|
||||
if qa_signoff and qa_signoff.get("status") == "approved":
|
||||
status = "qa_approved"
|
||||
elif qa_signoff and qa_signoff.get("status") == "rejected":
|
||||
status = "qa_rejected"
|
||||
elif (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_reviewed"
|
||||
else:
|
||||
status = "ready_to_build"
|
||||
elif phases["spec"]:
|
||||
status = "spec_complete"
|
||||
elif phases["requirements"]:
|
||||
status = "requirements_gathered"
|
||||
elif phases["discovery"]:
|
||||
status = "discovery_complete"
|
||||
else:
|
||||
status = "pending"
|
||||
|
||||
return {
|
||||
"spec_id": spec_dir.name,
|
||||
"spec_dir": str(spec_dir),
|
||||
"status": status,
|
||||
"phases": phases,
|
||||
}
|
||||
|
||||
def get_spec_content(self, spec_id: str) -> dict:
|
||||
"""Get the full content of a spec.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
|
||||
Returns:
|
||||
Dict with spec.md content, requirements, implementation plan, etc.
|
||||
"""
|
||||
specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
spec_dir = self._resolve_spec_dir(specs_dir, spec_id)
|
||||
if spec_dir is None:
|
||||
return {"error": f"Spec '{spec_id}' not found"}
|
||||
|
||||
content: dict = {
|
||||
"spec_id": spec_dir.name,
|
||||
"spec_dir": str(spec_dir),
|
||||
}
|
||||
|
||||
# Read spec.md
|
||||
spec_md = spec_dir / "spec.md"
|
||||
if spec_md.exists():
|
||||
try:
|
||||
content["spec_md"] = spec_md.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
content["spec_md_error"] = str(e)
|
||||
|
||||
# Read requirements.json
|
||||
req = self._load_json(spec_dir / "requirements.json")
|
||||
if req is not None:
|
||||
content["requirements"] = req
|
||||
|
||||
# Read implementation_plan.json
|
||||
plan = self._load_json(spec_dir / "implementation_plan.json")
|
||||
if plan is not None:
|
||||
content["implementation_plan"] = plan
|
||||
|
||||
# Read complexity_assessment.json
|
||||
assessment = self._load_json(spec_dir / "complexity_assessment.json")
|
||||
if assessment is not None:
|
||||
content["complexity_assessment"] = assessment
|
||||
|
||||
# Read QA report if present
|
||||
qa_report = spec_dir / "qa_report.md"
|
||||
if qa_report.exists():
|
||||
try:
|
||||
content["qa_report"] = qa_report.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return content
|
||||
|
||||
def list_specs(self) -> list[dict]:
|
||||
"""List all specs in the project.
|
||||
|
||||
Returns:
|
||||
List of spec summary dicts
|
||||
"""
|
||||
specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
if not specs_dir.is_dir():
|
||||
return []
|
||||
|
||||
specs = []
|
||||
for item in sorted(specs_dir.iterdir()):
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
status_info = self.get_spec_status(item.name)
|
||||
specs.append(status_info)
|
||||
return specs
|
||||
|
||||
def _resolve_spec_dir(self, specs_dir: Path, spec_id: str) -> Path | None:
|
||||
"""Resolve a spec_id to its directory, supporting prefix matching.
|
||||
|
||||
Args:
|
||||
specs_dir: Parent specs directory
|
||||
spec_id: Full or prefix spec identifier
|
||||
|
||||
Returns:
|
||||
Path to spec directory or None
|
||||
"""
|
||||
# Direct match
|
||||
exact = specs_dir / spec_id
|
||||
if exact.is_dir():
|
||||
return exact
|
||||
|
||||
# Prefix match (e.g. '001' matches '001-my-feature')
|
||||
if specs_dir.is_dir():
|
||||
for item in specs_dir.iterdir():
|
||||
if item.is_dir() and item.name.startswith(spec_id):
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
def _load_json(self, path: Path) -> dict | None:
|
||||
"""Safely load a JSON file.
|
||||
|
||||
Args:
|
||||
path: Path to the JSON file
|
||||
|
||||
Returns:
|
||||
Parsed dict or None
|
||||
"""
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load %s: %s", path, e)
|
||||
return None
|
||||
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Task Service
|
||||
=============
|
||||
|
||||
Loads, creates, updates, and deletes tasks by scanning spec directories.
|
||||
Ported from the TypeScript ProjectStore.loadTasksFromSpecsDir() logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid task statuses used by the backend pipeline
|
||||
VALID_STATUSES = frozenset(
|
||||
{
|
||||
"pending",
|
||||
"spec_creating",
|
||||
"planning",
|
||||
"in_progress",
|
||||
"qa_review",
|
||||
"qa_fixing",
|
||||
"human_review",
|
||||
"done",
|
||||
"failed",
|
||||
"cancelled",
|
||||
}
|
||||
)
|
||||
|
||||
# Status priority for deduplication (higher = more "complete")
|
||||
_STATUS_PRIORITY: dict[str, int] = {
|
||||
"done": 100,
|
||||
"human_review": 80,
|
||||
"qa_fixing": 70,
|
||||
"qa_review": 65,
|
||||
"in_progress": 50,
|
||||
"planning": 40,
|
||||
"spec_creating": 35,
|
||||
"pending": 20,
|
||||
"cancelled": 15,
|
||||
"failed": 10,
|
||||
}
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Convert a title into a filesystem-safe slug."""
|
||||
slug = text.lower().strip()
|
||||
slug = re.sub(r"[^\w\s-]", "", slug)
|
||||
slug = re.sub(r"[\s_]+", "-", slug)
|
||||
slug = re.sub(r"-+", "-", slug)
|
||||
return slug.strip("-")[:80]
|
||||
|
||||
|
||||
def _safe_read_json(path: Path) -> dict | None:
|
||||
"""Read a JSON file, returning None on any error."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_spec_heading(spec_path: Path) -> str | None:
|
||||
"""Extract the first markdown heading from a spec.md file."""
|
||||
try:
|
||||
content = spec_path.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$", content, re.MULTILINE
|
||||
)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_spec_overview(spec_path: Path) -> str | None:
|
||||
"""Extract the Overview section from a spec.md file."""
|
||||
try:
|
||||
content = spec_path.read_text(encoding="utf-8")
|
||||
match = re.search(r"## Overview\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)", content)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class TaskService:
|
||||
"""Manages task lifecycle by reading/writing spec directories."""
|
||||
|
||||
def __init__(self, project_dir: Path) -> None:
|
||||
self.project_dir = project_dir
|
||||
self.specs_dir = project_dir / ".auto-claude" / "specs"
|
||||
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_tasks(self) -> list[dict]:
|
||||
"""Scan spec directories and build a deduplicated task list.
|
||||
|
||||
Scans both the main project specs dir and worktree specs dirs.
|
||||
Main project tasks take priority over worktree duplicates.
|
||||
"""
|
||||
all_tasks: list[dict] = []
|
||||
main_spec_ids: set[str] = set()
|
||||
|
||||
# 1. Scan main project specs
|
||||
if self.specs_dir.is_dir():
|
||||
main_tasks = self._load_tasks_from_specs_dir(self.specs_dir, "main")
|
||||
all_tasks.extend(main_tasks)
|
||||
main_spec_ids = {t["spec_id"] for t in main_tasks}
|
||||
|
||||
# 2. Scan worktree specs (only include if spec exists in main)
|
||||
if self.worktrees_dir.is_dir():
|
||||
try:
|
||||
for worktree_dir in sorted(self.worktrees_dir.iterdir()):
|
||||
if not worktree_dir.is_dir():
|
||||
continue
|
||||
wt_specs = worktree_dir / ".auto-claude" / "specs"
|
||||
if wt_specs.is_dir():
|
||||
wt_tasks = self._load_tasks_from_specs_dir(wt_specs, "worktree")
|
||||
valid = [t for t in wt_tasks if t["spec_id"] in main_spec_ids]
|
||||
all_tasks.extend(valid)
|
||||
except OSError as exc:
|
||||
logger.warning("Error scanning worktrees: %s", exc)
|
||||
|
||||
# 3. Deduplicate — prefer main over worktree
|
||||
task_map: dict[str, dict] = {}
|
||||
for task in all_tasks:
|
||||
existing = task_map.get(task["spec_id"])
|
||||
if existing is None:
|
||||
task_map[task["spec_id"]] = task
|
||||
else:
|
||||
existing_is_main = existing.get("location") == "main"
|
||||
new_is_main = task.get("location") == "main"
|
||||
|
||||
if existing_is_main and not new_is_main:
|
||||
# Keep existing main
|
||||
continue
|
||||
elif not existing_is_main and new_is_main:
|
||||
# Replace worktree with main
|
||||
task_map[task["spec_id"]] = task
|
||||
else:
|
||||
# Same location — use status priority
|
||||
ep = _STATUS_PRIORITY.get(existing.get("status", ""), 0)
|
||||
np = _STATUS_PRIORITY.get(task.get("status", ""), 0)
|
||||
if np > ep:
|
||||
task_map[task["spec_id"]] = task
|
||||
|
||||
return list(task_map.values())
|
||||
|
||||
def get_task(self, spec_id: str) -> dict | None:
|
||||
"""Get full details for a single task by spec_id."""
|
||||
spec_dir = self.specs_dir / spec_id
|
||||
if not spec_dir.is_dir():
|
||||
# Try worktrees
|
||||
spec_dir = self._find_spec_dir_in_worktrees(spec_id)
|
||||
if spec_dir is None:
|
||||
return None
|
||||
return self._load_single_task(spec_dir, "main")
|
||||
|
||||
def create_task(self, title: str, description: str) -> dict:
|
||||
"""Create a new spec directory with initial files.
|
||||
|
||||
Returns the created task dict.
|
||||
"""
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
next_num = self._next_spec_number()
|
||||
slug = _slugify(title)
|
||||
dir_name = f"{next_num:03d}-{slug}" if slug else f"{next_num:03d}"
|
||||
spec_dir = self.specs_dir / dir_name
|
||||
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Write requirements.json
|
||||
requirements = {"task_description": description}
|
||||
(spec_dir / "requirements.json").write_text(
|
||||
json.dumps(requirements, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Write implementation_plan.json
|
||||
plan = {
|
||||
"feature": title,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "pending",
|
||||
"phases": [],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(
|
||||
json.dumps(plan, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Write task_metadata.json
|
||||
metadata = {
|
||||
"created_at": now,
|
||||
"source": "mcp",
|
||||
}
|
||||
(spec_dir / "task_metadata.json").write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
return self._load_single_task(spec_dir, "main") or {
|
||||
"spec_id": dir_name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
spec_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Update task metadata/plan fields."""
|
||||
spec_dir = self.specs_dir / spec_id
|
||||
if not spec_dir.is_dir():
|
||||
return None
|
||||
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
plan = _safe_read_json(plan_path) or {}
|
||||
changed = False
|
||||
|
||||
if title is not None:
|
||||
plan["feature"] = title
|
||||
plan["title"] = title
|
||||
changed = True
|
||||
|
||||
if description is not None:
|
||||
plan["description"] = description
|
||||
# Also update requirements
|
||||
req_path = spec_dir / "requirements.json"
|
||||
reqs = _safe_read_json(req_path) or {}
|
||||
reqs["task_description"] = description
|
||||
req_path.write_text(json.dumps(reqs, indent=2), encoding="utf-8")
|
||||
changed = True
|
||||
|
||||
if status is not None:
|
||||
if status not in VALID_STATUSES:
|
||||
return None
|
||||
plan["status"] = status
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
plan["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
plan_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")
|
||||
|
||||
return self._load_single_task(spec_dir, "main")
|
||||
|
||||
def delete_task(self, spec_id: str) -> bool:
|
||||
"""Delete a spec directory. Returns True if deleted."""
|
||||
spec_dir = self.specs_dir / spec_id
|
||||
if not spec_dir.is_dir():
|
||||
return False
|
||||
|
||||
# Safety: ensure it's actually within specs_dir (prevent traversal)
|
||||
try:
|
||||
spec_dir.resolve().relative_to(self.specs_dir.resolve())
|
||||
except ValueError:
|
||||
logger.error("Path traversal detected for spec_id: %s", spec_id)
|
||||
return False
|
||||
|
||||
shutil.rmtree(spec_dir)
|
||||
return True
|
||||
|
||||
def update_status(self, spec_id: str, status: str) -> dict | None:
|
||||
"""Update just the status field in implementation_plan.json."""
|
||||
if status not in VALID_STATUSES:
|
||||
return None
|
||||
return self.update_task(spec_id, status=status)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _next_spec_number(self) -> int:
|
||||
"""Find the highest existing spec number and return next."""
|
||||
max_num = 0
|
||||
if self.specs_dir.is_dir():
|
||||
for entry in self.specs_dir.iterdir():
|
||||
if entry.is_dir():
|
||||
match = re.match(r"^(\d{3})-", entry.name)
|
||||
if match:
|
||||
max_num = max(max_num, int(match.group(1)))
|
||||
return max_num + 1
|
||||
|
||||
def _find_spec_dir_in_worktrees(self, spec_id: str) -> Path | None:
|
||||
"""Search worktree directories for a spec."""
|
||||
if not self.worktrees_dir.is_dir():
|
||||
return None
|
||||
for wt_dir in self.worktrees_dir.iterdir():
|
||||
if not wt_dir.is_dir():
|
||||
continue
|
||||
candidate = wt_dir / ".auto-claude" / "specs" / spec_id
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _load_tasks_from_specs_dir(self, specs_dir: Path, location: str) -> list[dict]:
|
||||
"""Load all tasks from a specs directory."""
|
||||
tasks: list[dict] = []
|
||||
|
||||
try:
|
||||
entries = sorted(specs_dir.iterdir())
|
||||
except OSError as exc:
|
||||
logger.warning("Error reading specs directory %s: %s", specs_dir, exc)
|
||||
return []
|
||||
|
||||
for entry in entries:
|
||||
if not entry.is_dir() or entry.name == ".gitkeep":
|
||||
continue
|
||||
try:
|
||||
task = self._load_single_task(entry, location)
|
||||
if task:
|
||||
tasks.append(task)
|
||||
except Exception as exc:
|
||||
logger.warning("Error loading spec %s: %s", entry.name, exc)
|
||||
|
||||
return tasks
|
||||
|
||||
def _load_single_task(self, spec_dir: Path, location: str) -> dict | None:
|
||||
"""Load a single task from its spec directory."""
|
||||
dir_name = spec_dir.name
|
||||
|
||||
# Read implementation plan
|
||||
plan = _safe_read_json(spec_dir / "implementation_plan.json")
|
||||
|
||||
# Read requirements
|
||||
requirements = _safe_read_json(spec_dir / "requirements.json")
|
||||
|
||||
# Read metadata
|
||||
metadata = _safe_read_json(spec_dir / "task_metadata.json")
|
||||
|
||||
# Determine title (priority: plan.feature > plan.title > dir name)
|
||||
title = (plan or {}).get("feature") or (plan or {}).get("title") or dir_name
|
||||
|
||||
# If title looks like a spec ID (e.g. "054-some-slug"), try spec.md heading
|
||||
if re.match(r"^\d{3}-", title):
|
||||
spec_heading = _extract_spec_heading(spec_dir / "spec.md")
|
||||
if spec_heading:
|
||||
title = spec_heading
|
||||
|
||||
# Determine description (priority: plan.description > requirements.task_description > spec.md overview)
|
||||
description = ""
|
||||
if plan and plan.get("description"):
|
||||
description = plan["description"]
|
||||
if not description and requirements and requirements.get("task_description"):
|
||||
description = requirements["task_description"]
|
||||
if not description:
|
||||
overview = _extract_spec_overview(spec_dir / "spec.md")
|
||||
if overview:
|
||||
description = overview
|
||||
|
||||
# Determine status
|
||||
status = "pending"
|
||||
if plan and plan.get("status"):
|
||||
raw_status = plan["status"]
|
||||
# Map frontend-style statuses to valid backend statuses
|
||||
status_map: dict[str, str] = {
|
||||
"pending": "pending",
|
||||
"backlog": "pending",
|
||||
"queue": "pending",
|
||||
"queued": "pending",
|
||||
"spec_creating": "spec_creating",
|
||||
"planning": "planning",
|
||||
"coding": "in_progress",
|
||||
"in_progress": "in_progress",
|
||||
"review": "qa_review",
|
||||
"ai_review": "qa_review",
|
||||
"qa_review": "qa_review",
|
||||
"qa_fixing": "qa_fixing",
|
||||
"human_review": "human_review",
|
||||
"completed": "done",
|
||||
"done": "done",
|
||||
"pr_created": "done",
|
||||
"error": "failed",
|
||||
"failed": "failed",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
status = status_map.get(raw_status, "pending")
|
||||
|
||||
# Extract subtasks from plan phases
|
||||
subtasks: list[dict] = []
|
||||
if plan and plan.get("phases"):
|
||||
for phase in plan["phases"]:
|
||||
items = phase.get("subtasks") or phase.get("chunks") or []
|
||||
for st in items:
|
||||
subtasks.append(
|
||||
{
|
||||
"id": st.get("id", ""),
|
||||
"title": st.get("description", ""),
|
||||
"status": st.get("status", "pending"),
|
||||
}
|
||||
)
|
||||
|
||||
# Build result
|
||||
created_at = (plan or {}).get("created_at", "")
|
||||
updated_at = (plan or {}).get("updated_at", "")
|
||||
|
||||
return {
|
||||
"spec_id": dir_name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": status,
|
||||
"subtasks": subtasks,
|
||||
"metadata": metadata,
|
||||
"location": location,
|
||||
"specs_path": str(spec_dir),
|
||||
"has_spec": (spec_dir / "spec.md").exists(),
|
||||
"has_plan": (spec_dir / "implementation_plan.json").exists(),
|
||||
"has_qa_report": (spec_dir / "qa_report.md").exists(),
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Workspace Service
|
||||
==================
|
||||
|
||||
Service layer wrapping the backend WorktreeManager for MCP tool consumption.
|
||||
Handles git worktree operations: list, diff, merge, discard, and PR creation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
"""Wraps WorktreeManager operations for MCP server use."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
|
||||
def _get_manager(self):
|
||||
"""Lazily create a WorktreeManager instance.
|
||||
|
||||
Returns:
|
||||
WorktreeManager instance
|
||||
|
||||
Raises:
|
||||
ImportError: If backend module is not available
|
||||
"""
|
||||
from core.worktree import WorktreeManager
|
||||
|
||||
return WorktreeManager(self.project_dir)
|
||||
|
||||
def list_worktrees(self) -> dict:
|
||||
"""List all active git worktrees for the project.
|
||||
|
||||
Returns:
|
||||
Dict with list of worktree info dicts
|
||||
"""
|
||||
try:
|
||||
manager = self._get_manager()
|
||||
except ImportError as e:
|
||||
return {"error": f"Backend module not available: {e}"}
|
||||
|
||||
try:
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
worktrees = manager.list_all_worktrees()
|
||||
|
||||
result = []
|
||||
for wt in worktrees:
|
||||
entry = {
|
||||
"spec_name": wt.spec_name,
|
||||
"branch": wt.branch,
|
||||
"path": str(wt.path),
|
||||
"base_branch": wt.base_branch,
|
||||
"is_active": wt.is_active,
|
||||
"commit_count": wt.commit_count,
|
||||
"files_changed": wt.files_changed,
|
||||
"additions": wt.additions,
|
||||
"deletions": wt.deletions,
|
||||
}
|
||||
if wt.days_since_last_commit is not None:
|
||||
entry["days_since_last_commit"] = wt.days_since_last_commit
|
||||
if wt.last_commit_date is not None:
|
||||
entry["last_commit_date"] = wt.last_commit_date.isoformat()
|
||||
result.append(entry)
|
||||
|
||||
return {"worktrees": result, "count": len(result)}
|
||||
except Exception as e:
|
||||
logger.exception("Failed to list worktrees")
|
||||
return {"error": str(e)}
|
||||
|
||||
def get_diff(self, spec_id: str) -> dict:
|
||||
"""Get the git diff for a spec's worktree.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
|
||||
Returns:
|
||||
Dict with diff content and change summary
|
||||
"""
|
||||
try:
|
||||
manager = self._get_manager()
|
||||
except ImportError as e:
|
||||
return {"error": f"Backend module not available: {e}"}
|
||||
|
||||
try:
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
info = manager.get_worktree_info(spec_id)
|
||||
|
||||
if info is None:
|
||||
return {"error": f"No worktree found for spec '{spec_id}'"}
|
||||
|
||||
# Get changed files
|
||||
files = manager.get_changed_files(spec_id)
|
||||
summary = manager.get_change_summary(spec_id)
|
||||
|
||||
# Get actual diff content
|
||||
from core.git_executable import run_git
|
||||
|
||||
diff_result = run_git(
|
||||
["diff", f"{info.base_branch}...HEAD"],
|
||||
cwd=info.path,
|
||||
)
|
||||
diff_content = ""
|
||||
if diff_result.returncode == 0:
|
||||
diff_content = diff_result.stdout
|
||||
# Truncate very large diffs
|
||||
if len(diff_content) > 50000:
|
||||
diff_content = (
|
||||
diff_content[:50000]
|
||||
+ "\n\n... (diff truncated, total length: "
|
||||
+ str(len(diff_result.stdout))
|
||||
+ " chars)"
|
||||
)
|
||||
|
||||
return {
|
||||
"spec_id": spec_id,
|
||||
"branch": info.branch,
|
||||
"base_branch": info.base_branch,
|
||||
"changed_files": [
|
||||
{"status": status, "path": path} for status, path in files
|
||||
],
|
||||
"summary": summary,
|
||||
"diff": diff_content,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Failed to get diff for %s", spec_id)
|
||||
return {"error": str(e)}
|
||||
|
||||
async def merge(self, spec_id: str, strategy: str = "auto") -> dict:
|
||||
"""Merge a spec's worktree changes back to the main branch.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
strategy: Merge strategy - 'auto' (git merge), 'no-commit' (stage only)
|
||||
|
||||
Returns:
|
||||
Dict with merge result
|
||||
"""
|
||||
try:
|
||||
manager = self._get_manager()
|
||||
except ImportError as e:
|
||||
return {"error": f"Backend module not available: {e}"}
|
||||
|
||||
try:
|
||||
no_commit = strategy == "no-commit"
|
||||
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
success = manager.merge_worktree(
|
||||
spec_id,
|
||||
delete_after=False,
|
||||
no_commit=no_commit,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"spec_id": spec_id,
|
||||
"strategy": strategy,
|
||||
"output": captured.getvalue()[-2000:] if captured.getvalue() else "",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Failed to merge worktree for %s", spec_id)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def discard(self, spec_id: str) -> dict:
|
||||
"""Discard a spec's worktree and optionally its branch.
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
|
||||
Returns:
|
||||
Dict with discard result
|
||||
"""
|
||||
try:
|
||||
manager = self._get_manager()
|
||||
except ImportError as e:
|
||||
return {"error": f"Backend module not available: {e}"}
|
||||
|
||||
try:
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
manager.remove_worktree(spec_id, delete_branch=True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"spec_id": spec_id,
|
||||
"message": f"Worktree and branch for '{spec_id}' removed",
|
||||
"output": captured.getvalue() if captured.getvalue() else "",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Failed to discard worktree for %s", spec_id)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
spec_id: str,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict:
|
||||
"""Push branch and create a pull request from a spec's worktree.
|
||||
|
||||
Automatically detects the git provider (GitHub/GitLab).
|
||||
|
||||
Args:
|
||||
spec_id: The spec folder name
|
||||
title: PR title (defaults to spec name)
|
||||
body: PR body (defaults to spec summary)
|
||||
|
||||
Returns:
|
||||
Dict with PR URL and status
|
||||
"""
|
||||
try:
|
||||
manager = self._get_manager()
|
||||
except ImportError as e:
|
||||
return {"error": f"Backend module not available: {e}"}
|
||||
|
||||
try:
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured):
|
||||
result = manager.push_and_create_pr(
|
||||
spec_name=spec_id,
|
||||
title=title,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result.get("success", False),
|
||||
"spec_id": spec_id,
|
||||
"pr_url": result.get("pr_url"),
|
||||
"branch": result.get("branch"),
|
||||
"provider": result.get("provider"),
|
||||
"already_exists": result.get("already_exists", False),
|
||||
"error": result.get("error"),
|
||||
"output": captured.getvalue()[-1000:] if captured.getvalue() else "",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create PR for %s", spec_id)
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -0,0 +1 @@
|
||||
"""MCP tool modules - each module registers tools with the FastMCP server."""
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Execution Tools
|
||||
================
|
||||
|
||||
MCP tools for starting, stopping, and monitoring builds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def build_start(
|
||||
spec_id: str,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
) -> dict:
|
||||
"""Start building/implementing a spec. This is a long-running operation.
|
||||
|
||||
Spawns the autonomous coding pipeline which creates a worktree, runs
|
||||
the planner, then executes each subtask with parallel agents.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix (e.g. '001' or '001-my-feature')
|
||||
model: Model to use - 'sonnet' (fast), 'opus' (thorough)
|
||||
thinking_level: Reasoning depth - 'low', 'medium', 'high'
|
||||
|
||||
Returns:
|
||||
An operation_id to poll with operation_get_status() for progress
|
||||
"""
|
||||
op = tracker.create("build", f"Building spec: {spec_id}")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
from mcp_server.services.execution_service import get_execution_service
|
||||
|
||||
service = get_execution_service(get_project_dir())
|
||||
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=5,
|
||||
message="Spawning build process...",
|
||||
)
|
||||
|
||||
proc = await service.start_build(
|
||||
spec_id=spec_id,
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="Build process started, waiting for completion...",
|
||||
)
|
||||
|
||||
# Wait for the process to complete
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
progress_info = service.get_progress(spec_id)
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Build completed successfully",
|
||||
result=progress_info,
|
||||
)
|
||||
else:
|
||||
logs = service.get_logs(spec_id, tail=20)
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=f"Build exited with code {proc.returncode}",
|
||||
result={
|
||||
"exit_code": proc.returncode,
|
||||
"tail_logs": logs.get("lines", []),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("build_start operation failed")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "Build started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def build_stop(spec_id: str) -> dict:
|
||||
"""Stop a running build.
|
||||
|
||||
Terminates the build subprocess. The worktree and any partial changes
|
||||
are preserved so the build can be resumed later.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
|
||||
Returns:
|
||||
Whether the build was successfully stopped
|
||||
"""
|
||||
from mcp_server.services.execution_service import get_execution_service
|
||||
|
||||
service = get_execution_service(get_project_dir())
|
||||
return service.stop_build(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def build_get_progress(spec_id: str) -> dict:
|
||||
"""Get progress of a running or completed build.
|
||||
|
||||
Shows subtask completion status, QA state, and whether the build
|
||||
is still running.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
|
||||
Returns:
|
||||
Build progress including subtask completion and QA status
|
||||
"""
|
||||
from mcp_server.services.execution_service import get_execution_service
|
||||
|
||||
service = get_execution_service(get_project_dir())
|
||||
return service.get_progress(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def build_get_logs(spec_id: str, tail: int = 50) -> dict:
|
||||
"""Get recent build logs for a spec.
|
||||
|
||||
Returns the most recent log lines from the build process output.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
tail: Number of recent lines to return (default 50)
|
||||
|
||||
Returns:
|
||||
Recent log lines from the build
|
||||
"""
|
||||
from mcp_server.services.execution_service import get_execution_service
|
||||
|
||||
service = get_execution_service(get_project_dir())
|
||||
return service.get_logs(spec_id, tail=tail)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
GitHub Tools
|
||||
=============
|
||||
|
||||
MCP tools for GitHub automation: PR review, issue triage, auto-fix.
|
||||
Long-running operations return an operation_id for polling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
from mcp_server.services.github_service import GitHubService
|
||||
|
||||
|
||||
def _get_service() -> GitHubService:
|
||||
return GitHubService(get_project_dir())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def github_review_pr(
|
||||
pr_number: int, repo: str | None = None, model: str = "sonnet"
|
||||
) -> dict:
|
||||
"""Review a pull request with AI. Long-running operation - returns operation_id.
|
||||
|
||||
Performs a multi-pass AI code review including security, quality,
|
||||
structural analysis, and AI comment triage.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number to review
|
||||
repo: Repository in owner/repo format (auto-detected from git remote if omitted)
|
||||
model: Model to use (haiku, sonnet, opus)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create("github_review_pr", f"Starting review of PR #{pr_number}...")
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message=f"Reviewing PR #{pr_number}...",
|
||||
)
|
||||
result = await service.review_pr(pr_number, repo, model)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Review complete",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": f"PR #{pr_number} review started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def github_list_issues(
|
||||
state: str = "open", limit: int = 30, repo: str | None = None
|
||||
) -> dict:
|
||||
"""List GitHub issues for the project.
|
||||
|
||||
Args:
|
||||
state: Issue state filter: open, closed, or all
|
||||
limit: Maximum number of issues to return
|
||||
repo: Repository in owner/repo format (auto-detected if omitted)
|
||||
|
||||
Returns:
|
||||
List of issues with number, title, state, labels, author
|
||||
"""
|
||||
service = _get_service()
|
||||
return await service.list_issues(state, limit, repo)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def github_auto_fix(issue_number: int, repo: str | None = None) -> dict:
|
||||
"""Automatically fix a GitHub issue by creating a spec and building it. Long-running.
|
||||
|
||||
Creates a specification from the issue, builds it through the autonomous
|
||||
pipeline (planner -> coder -> QA), and optionally creates a PR.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number to auto-fix
|
||||
repo: Repository in owner/repo format (auto-detected if omitted)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create(
|
||||
"github_auto_fix", f"Starting auto-fix for issue #{issue_number}..."
|
||||
)
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message=f"Auto-fixing issue #{issue_number}...",
|
||||
)
|
||||
result = await service.auto_fix_issue(issue_number, repo)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Auto-fix complete",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": f"Auto-fix for issue #{issue_number} started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def github_get_review(pr_number: int) -> dict:
|
||||
"""Get the most recent review result for a PR.
|
||||
|
||||
Returns the saved review data including findings, verdict, and summary.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number to get the review for
|
||||
|
||||
Returns:
|
||||
Review result with findings, verdict, blockers, and summary
|
||||
"""
|
||||
service = _get_service()
|
||||
return service.get_review(pr_number)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def github_triage_issues(
|
||||
issue_numbers: list[int], repo: str | None = None
|
||||
) -> dict:
|
||||
"""Triage and classify GitHub issues. Long-running.
|
||||
|
||||
Analyzes issues for duplicates, spam, feature creep, and assigns
|
||||
categories, priority, and suggested labels.
|
||||
|
||||
Args:
|
||||
issue_numbers: List of issue numbers to triage
|
||||
repo: Repository in owner/repo format (auto-detected if omitted)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create(
|
||||
"github_triage_issues",
|
||||
f"Starting triage of {len(issue_numbers)} issues...",
|
||||
)
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message=f"Triaging {len(issue_numbers)} issues...",
|
||||
)
|
||||
result = await service.triage_issues(issue_numbers, repo)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message=f"Triaged {result.get('count', 0)} issues",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": f"Triage of {len(issue_numbers)} issues started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Ideation Tools
|
||||
===============
|
||||
|
||||
MCP tools for AI-powered project ideation and improvement discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
from mcp_server.services.ideation_service import IdeationService
|
||||
|
||||
|
||||
def _get_service() -> IdeationService:
|
||||
return IdeationService(get_project_dir())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def ideation_generate(
|
||||
types: list[str] | None = None,
|
||||
refresh: bool = False,
|
||||
model: str = "sonnet",
|
||||
) -> dict:
|
||||
"""Generate ideas for project improvements. Long-running.
|
||||
|
||||
Analyzes the codebase and generates actionable improvement ideas
|
||||
across multiple categories.
|
||||
|
||||
Args:
|
||||
types: Ideation types to generate. Options: low_hanging_fruit,
|
||||
ui_ux_improvements, high_value_features. Defaults to all.
|
||||
refresh: Force regeneration of existing ideation data
|
||||
model: Model to use (haiku, sonnet, opus)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create("ideation_generate", "Starting ideation generation...")
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="Analyzing project for improvement ideas...",
|
||||
)
|
||||
result = await service.generate(types=types, refresh=refresh, model=model)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Ideation complete",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "Ideation generation started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ideation_get() -> dict:
|
||||
"""Get previously generated ideation results.
|
||||
|
||||
Returns all generated ideas with their categories, priorities,
|
||||
effort estimates, and implementation suggestions.
|
||||
|
||||
Returns:
|
||||
Ideation data with ideas grouped by type and priority
|
||||
"""
|
||||
service = _get_service()
|
||||
return service.get_ideation()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Insights Tools
|
||||
===============
|
||||
|
||||
MCP tools for AI-powered codebase insights and Q&A.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
from mcp_server.services.insights_service import InsightsService
|
||||
|
||||
|
||||
def _get_service() -> InsightsService:
|
||||
return InsightsService(get_project_dir())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def insights_ask(
|
||||
question: str, history: list | None = None, model: str = "sonnet"
|
||||
) -> dict:
|
||||
"""Ask an AI question about the codebase. Long-running operation.
|
||||
|
||||
The AI agent has access to the codebase and can read files, search,
|
||||
and explore to answer questions about architecture, patterns, bugs, etc.
|
||||
|
||||
Args:
|
||||
question: The question to ask about the codebase
|
||||
history: Optional conversation history as list of {role, content} dicts
|
||||
model: Model to use (haiku, sonnet, opus)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create("insights_ask", "Processing question...")
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="AI is exploring the codebase...",
|
||||
)
|
||||
result = await service.ask(question, history, model)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Question answered",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "Insights query started. Poll operation_get_status() for the answer.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def insights_suggest_tasks() -> dict:
|
||||
"""Get AI-suggested tasks based on recent insights conversations.
|
||||
|
||||
Returns task suggestions derived from ideation data or previous
|
||||
insights conversations.
|
||||
|
||||
Returns:
|
||||
List of task suggestions with title, description, category, impact
|
||||
"""
|
||||
service = _get_service()
|
||||
return service.suggest_tasks()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Memory Tools
|
||||
=============
|
||||
|
||||
MCP tools for Graphiti-based semantic memory (knowledge graph).
|
||||
Requires GRAPHITI_ENABLED=true in the environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.server import mcp
|
||||
from mcp_server.services.memory_service import get_memory_service
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def memory_search(query: str, limit: int = 10) -> dict:
|
||||
"""Search the project's semantic memory (Graphiti knowledge graph).
|
||||
|
||||
Finds relevant stored knowledge including codebase discoveries,
|
||||
session insights, patterns, gotchas, and task outcomes.
|
||||
|
||||
Requires GRAPHITI_ENABLED=true in environment.
|
||||
|
||||
Args:
|
||||
query: Search query describing what you're looking for
|
||||
limit: Maximum number of results to return (default 10)
|
||||
|
||||
Returns:
|
||||
List of relevant memory entries with content and relevance scores
|
||||
"""
|
||||
service = get_memory_service(get_project_dir())
|
||||
return await service.search(query, limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def memory_add_episode(content: str, source: str = "mcp") -> dict:
|
||||
"""Add a new episode/fact to the project's memory.
|
||||
|
||||
Stores information in the knowledge graph for future retrieval.
|
||||
Use this to record insights, patterns, or important findings.
|
||||
|
||||
Requires GRAPHITI_ENABLED=true in environment.
|
||||
|
||||
Args:
|
||||
content: The information to store (insight, pattern, discovery, etc.)
|
||||
source: Source identifier for the episode (default: mcp)
|
||||
|
||||
Returns:
|
||||
Confirmation of successful storage
|
||||
"""
|
||||
service = get_memory_service(get_project_dir())
|
||||
return await service.add_episode(content, source)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def memory_get_recent(limit: int = 10) -> dict:
|
||||
"""Get recent memory entries.
|
||||
|
||||
Retrieves the most recent entries from the project's knowledge graph.
|
||||
|
||||
Requires GRAPHITI_ENABLED=true in environment.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return (default 10)
|
||||
|
||||
Returns:
|
||||
List of recent memory entries
|
||||
"""
|
||||
service = get_memory_service(get_project_dir())
|
||||
return await service.get_recent(limit)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Operations Management Tools
|
||||
============================
|
||||
|
||||
Tools for polling long-running operation status and cancelling operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_server.operations import tracker
|
||||
from mcp_server.server import mcp
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def operation_get_status(operation_id: str) -> dict:
|
||||
"""Get the status of a long-running operation.
|
||||
|
||||
Use this to poll for progress on operations started by tools like
|
||||
spec_create, build_start, qa_start_review, etc.
|
||||
|
||||
Args:
|
||||
operation_id: The operation ID returned by the tool that started the operation
|
||||
|
||||
Returns:
|
||||
Operation status including progress (0-100), message, and result when complete
|
||||
"""
|
||||
op = tracker.get(operation_id)
|
||||
if op is None:
|
||||
return {"error": f"Operation {operation_id} not found"}
|
||||
return op.to_dict()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def operation_cancel(operation_id: str) -> dict:
|
||||
"""Cancel a running operation.
|
||||
|
||||
Args:
|
||||
operation_id: The operation ID to cancel
|
||||
|
||||
Returns:
|
||||
Whether the cancellation was successful
|
||||
"""
|
||||
success = tracker.cancel(operation_id)
|
||||
if not success:
|
||||
op = tracker.get(operation_id)
|
||||
if op is None:
|
||||
return {"success": False, "error": "Operation not found"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot cancel operation in {op.status.value} state",
|
||||
}
|
||||
return {"success": True, "message": "Operation cancelled"}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Project Management Tools
|
||||
=========================
|
||||
|
||||
MCP tools for managing the active project: switching projects,
|
||||
getting status, listing specs, and reading the project index.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from mcp_server import config
|
||||
from mcp_server.server import mcp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def project_set_active(project_dir: str) -> dict:
|
||||
"""Switch the MCP server to a different project directory.
|
||||
|
||||
Re-initializes the server to point at a new project.
|
||||
All subsequent tool calls will operate on this project.
|
||||
|
||||
Args:
|
||||
project_dir: Absolute path to the project directory
|
||||
"""
|
||||
try:
|
||||
config.initialize(project_dir)
|
||||
project_path = config.get_project_dir()
|
||||
initialized = config.is_initialized()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project_dir": str(project_path),
|
||||
"initialized": initialized,
|
||||
"message": f"Active project set to {project_path}",
|
||||
}
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def project_get_status() -> dict:
|
||||
"""Get the current project status.
|
||||
|
||||
Returns the active project directory, initialization state,
|
||||
specs count, and project index summary.
|
||||
"""
|
||||
try:
|
||||
project_dir = config.get_project_dir()
|
||||
except RuntimeError:
|
||||
return {
|
||||
"initialized": False,
|
||||
"error": "No project set. Use project_set_active() first.",
|
||||
}
|
||||
|
||||
initialized = config.is_initialized()
|
||||
specs_count = 0
|
||||
|
||||
if initialized:
|
||||
specs_dir = config.get_specs_dir()
|
||||
if specs_dir.is_dir():
|
||||
specs_count = sum(
|
||||
1
|
||||
for entry in specs_dir.iterdir()
|
||||
if entry.is_dir() and entry.name != ".gitkeep"
|
||||
)
|
||||
|
||||
index = config.get_project_index()
|
||||
|
||||
return {
|
||||
"project_dir": str(project_dir),
|
||||
"initialized": initialized,
|
||||
"specs_count": specs_count,
|
||||
"has_project_index": bool(index),
|
||||
"project_name": index.get("name", project_dir.name),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def project_list_specs() -> dict:
|
||||
"""List all spec directories with their basic info.
|
||||
|
||||
Returns each spec's name, whether it has a plan/spec file,
|
||||
and status from the implementation plan.
|
||||
"""
|
||||
try:
|
||||
specs_dir = config.get_specs_dir()
|
||||
except RuntimeError:
|
||||
return {"error": "No project set. Use project_set_active() first.", "specs": []}
|
||||
|
||||
if not specs_dir.is_dir():
|
||||
return {"specs": [], "message": "No specs directory found."}
|
||||
|
||||
specs: list[dict] = []
|
||||
for entry in sorted(specs_dir.iterdir()):
|
||||
if not entry.is_dir() or entry.name == ".gitkeep":
|
||||
continue
|
||||
|
||||
has_plan = (entry / "implementation_plan.json").exists()
|
||||
has_spec = (entry / "spec.md").exists()
|
||||
|
||||
status = "pending"
|
||||
title = entry.name
|
||||
if has_plan:
|
||||
try:
|
||||
import json
|
||||
|
||||
plan = json.loads(
|
||||
(entry / "implementation_plan.json").read_text(encoding="utf-8")
|
||||
)
|
||||
status = plan.get("status", "pending")
|
||||
title = plan.get("feature") or plan.get("title") or entry.name
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
specs.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"title": title,
|
||||
"has_plan": has_plan,
|
||||
"has_spec": has_spec,
|
||||
"has_qa_report": (entry / "qa_report.md").exists(),
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
return {"specs": specs, "count": len(specs)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def project_get_index() -> dict:
|
||||
"""Return the full project_index.json content.
|
||||
|
||||
The project index contains metadata about the project
|
||||
such as file summaries, dependency info, and analysis results.
|
||||
"""
|
||||
try:
|
||||
index = config.get_project_index()
|
||||
except RuntimeError:
|
||||
return {"error": "No project set. Use project_set_active() first."}
|
||||
|
||||
if not index:
|
||||
return {"message": "No project index found. Run indexing first.", "index": {}}
|
||||
|
||||
return {"index": index}
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
QA Tools
|
||||
=========
|
||||
|
||||
MCP tools for running QA reviews, getting reports, and manual approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def qa_start_review(spec_id: str) -> dict:
|
||||
"""Start QA review for a completed build. This is a long-running operation.
|
||||
|
||||
Runs the QA reviewer agent which validates the implementation against
|
||||
the spec's acceptance criteria. The agent reads code, runs tests, and
|
||||
produces a detailed QA report.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix (e.g. '001' or '001-my-feature')
|
||||
|
||||
Returns:
|
||||
An operation_id to poll with operation_get_status() for progress
|
||||
"""
|
||||
op = tracker.create("qa_review", f"QA review for: {spec_id}")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
from mcp_server.services.qa_service import QAService
|
||||
|
||||
service = QAService(get_project_dir())
|
||||
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="Starting QA review session...",
|
||||
)
|
||||
|
||||
result = await service.start_review(spec_id=spec_id)
|
||||
|
||||
status = result.get("status", "error")
|
||||
if status == "approved":
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="QA approved - all acceptance criteria validated",
|
||||
result=result,
|
||||
)
|
||||
elif status == "rejected":
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="QA rejected - issues found, see report",
|
||||
result=result,
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=result.get("error", "QA review failed"),
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("qa_start_review operation failed")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "QA review started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def qa_get_report(spec_id: str) -> dict:
|
||||
"""Get the QA report for a spec.
|
||||
|
||||
Returns the full QA report including validation results, issues found,
|
||||
and the qa_signoff status from the implementation plan.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
|
||||
Returns:
|
||||
QA report content, fix requests, and signoff status
|
||||
"""
|
||||
from mcp_server.services.qa_service import QAService
|
||||
|
||||
service = QAService(get_project_dir())
|
||||
return service.get_report(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def qa_approve(spec_id: str) -> dict:
|
||||
"""Manually approve a spec that's in QA review.
|
||||
|
||||
Use this to bypass the automated QA review and mark a spec as approved.
|
||||
This updates the implementation_plan.json qa_signoff status.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
|
||||
Returns:
|
||||
Whether the approval was successful
|
||||
"""
|
||||
from mcp_server.services.qa_service import QAService
|
||||
|
||||
service = QAService(get_project_dir())
|
||||
return service.approve(spec_id)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Roadmap Tools
|
||||
==============
|
||||
|
||||
MCP tools for AI-powered strategic roadmap generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
from mcp_server.services.roadmap_service import RoadmapService
|
||||
|
||||
|
||||
def _get_service() -> RoadmapService:
|
||||
return RoadmapService(get_project_dir())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def roadmap_generate(refresh: bool = False, model: str = "sonnet") -> dict:
|
||||
"""Generate a strategic roadmap for the project. Long-running.
|
||||
|
||||
Analyzes the project structure, existing features, and codebase to
|
||||
generate a phased roadmap with prioritized features.
|
||||
|
||||
Args:
|
||||
refresh: Force regeneration even if a roadmap already exists
|
||||
model: Model to use (haiku, sonnet, opus)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create("roadmap_generate", "Starting roadmap generation...")
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="Analyzing project for roadmap generation...",
|
||||
)
|
||||
result = await service.generate(refresh=refresh, model=model)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Roadmap generated",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "Roadmap generation started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def roadmap_get() -> dict:
|
||||
"""Get the current roadmap data.
|
||||
|
||||
Returns the previously generated roadmap including vision, phases,
|
||||
features with priorities, and implementation details.
|
||||
|
||||
Returns:
|
||||
Roadmap data with vision, phases, features, and priority breakdown
|
||||
"""
|
||||
service = _get_service()
|
||||
return service.get_roadmap()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def roadmap_refresh(model: str = "sonnet") -> dict:
|
||||
"""Refresh/regenerate the roadmap. Long-running.
|
||||
|
||||
Forces a complete regeneration of the roadmap, analyzing current
|
||||
project state and creating updated phases and features.
|
||||
|
||||
Args:
|
||||
model: Model to use (haiku, sonnet, opus)
|
||||
|
||||
Returns:
|
||||
Operation ID to poll with operation_get_status()
|
||||
"""
|
||||
service = _get_service()
|
||||
op = tracker.create("roadmap_refresh", "Starting roadmap refresh...")
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="Refreshing roadmap...",
|
||||
)
|
||||
result = await service.generate(refresh=True, model=model)
|
||||
if "error" in result:
|
||||
tracker.update(
|
||||
op.id, status=OperationStatus.FAILED, error=result["error"]
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Roadmap refreshed",
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error=str(e))
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "Roadmap refresh started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Spec Tools
|
||||
===========
|
||||
|
||||
MCP tools for creating and inspecting specifications.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def spec_create(
|
||||
task_description: str,
|
||||
model: str = "sonnet",
|
||||
thinking_level: str = "medium",
|
||||
) -> dict:
|
||||
"""Create a specification for a task. This is a long-running operation.
|
||||
|
||||
The spec creation pipeline runs multiple AI phases (discovery, requirements,
|
||||
complexity assessment, spec writing, planning) to produce a complete
|
||||
implementation-ready specification.
|
||||
|
||||
Args:
|
||||
task_description: What you want to build (be specific and detailed)
|
||||
model: Model to use - 'sonnet' (fast), 'opus' (thorough)
|
||||
thinking_level: How much the AI reasons - 'low', 'medium', 'high'
|
||||
|
||||
Returns:
|
||||
An operation_id to poll with operation_get_status() for progress
|
||||
"""
|
||||
op = tracker.create("spec_create", f"Creating spec for: {task_description[:80]}")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
from mcp_server.services.spec_service import SpecService
|
||||
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=5,
|
||||
message="Initializing spec pipeline...",
|
||||
)
|
||||
|
||||
service = SpecService(get_project_dir())
|
||||
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=10,
|
||||
message="Running spec creation phases...",
|
||||
)
|
||||
|
||||
result = await service.create_spec(
|
||||
task_description=task_description,
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="Spec created successfully",
|
||||
result=result,
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
progress=100,
|
||||
message="Spec creation failed",
|
||||
error=result.get("error", "Unknown error"),
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("spec_create operation failed")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "Spec creation started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def spec_get_status(spec_id: str) -> dict:
|
||||
"""Get the current status of a spec (which phases have completed).
|
||||
|
||||
Shows whether discovery, requirements, spec writing, and planning
|
||||
phases are complete, and the overall readiness state.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix (e.g. '001' or '001-my-feature')
|
||||
|
||||
Returns:
|
||||
Status including completed phases and overall state
|
||||
"""
|
||||
from mcp_server.services.spec_service import SpecService
|
||||
|
||||
service = SpecService(get_project_dir())
|
||||
return service.get_spec_status(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def spec_get_content(spec_id: str) -> dict:
|
||||
"""Get the full content of a spec including spec.md, requirements, and plan.
|
||||
|
||||
Returns the complete specification content so you can understand what
|
||||
will be built and how.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix (e.g. '001' or '001-my-feature')
|
||||
|
||||
Returns:
|
||||
Full spec content including spec.md, requirements.json, implementation_plan.json
|
||||
"""
|
||||
from mcp_server.services.spec_service import SpecService
|
||||
|
||||
service = SpecService(get_project_dir())
|
||||
return service.get_spec_content(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def spec_list() -> dict:
|
||||
"""List all specs in the project with their status.
|
||||
|
||||
Returns:
|
||||
List of specs with their current status and phase completion
|
||||
"""
|
||||
from mcp_server.services.spec_service import SpecService
|
||||
|
||||
service = SpecService(get_project_dir())
|
||||
specs = service.list_specs()
|
||||
return {"specs": specs, "count": len(specs)}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Task Management Tools
|
||||
======================
|
||||
|
||||
MCP tools for CRUD operations on tasks (specs).
|
||||
Tasks are stored as spec directories under .auto-claude/specs/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from mcp_server import config
|
||||
from mcp_server.server import mcp
|
||||
from mcp_server.services.task_service import VALID_STATUSES, TaskService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_task_service() -> TaskService:
|
||||
"""Get a TaskService instance for the active project."""
|
||||
return TaskService(config.get_project_dir())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def task_list() -> dict:
|
||||
"""List all tasks with id, title, status, and description preview.
|
||||
|
||||
Scans both the main project and worktree spec directories,
|
||||
deduplicating with main project taking priority.
|
||||
"""
|
||||
try:
|
||||
service = _get_task_service()
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc), "tasks": []}
|
||||
|
||||
tasks = service.list_tasks()
|
||||
|
||||
# Return a concise view for listing
|
||||
summary = []
|
||||
for t in tasks:
|
||||
desc = t.get("description", "")
|
||||
preview = (desc[:200] + "...") if len(desc) > 200 else desc
|
||||
summary.append(
|
||||
{
|
||||
"spec_id": t["spec_id"],
|
||||
"title": t["title"],
|
||||
"status": t["status"],
|
||||
"description_preview": preview,
|
||||
"has_spec": t.get("has_spec", False),
|
||||
"has_plan": t.get("has_plan", False),
|
||||
"subtask_count": len(t.get("subtasks", [])),
|
||||
}
|
||||
)
|
||||
|
||||
return {"tasks": summary, "count": len(summary)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def task_create(title: str, description: str) -> dict:
|
||||
"""Create a new task (spec directory) with initial files.
|
||||
|
||||
Generates the next spec number automatically and creates
|
||||
the directory with requirements.json, implementation_plan.json,
|
||||
and task_metadata.json.
|
||||
|
||||
Args:
|
||||
title: The task title (used for the directory name slug)
|
||||
description: Full description of what needs to be done
|
||||
"""
|
||||
try:
|
||||
service = _get_task_service()
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
if not title or not title.strip():
|
||||
return {"error": "Title is required"}
|
||||
if not description or not description.strip():
|
||||
return {"error": "Description is required"}
|
||||
|
||||
task = service.create_task(title.strip(), description.strip())
|
||||
return {"success": True, "task": task}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def task_get(spec_id: str) -> dict:
|
||||
"""Get full task details including subtasks, metadata, and file info.
|
||||
|
||||
Args:
|
||||
spec_id: The spec directory name (e.g., "001-my-feature")
|
||||
"""
|
||||
try:
|
||||
service = _get_task_service()
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
task = service.get_task(spec_id)
|
||||
if task is None:
|
||||
return {"error": f"Task '{spec_id}' not found"}
|
||||
|
||||
return {"task": task}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def task_update(
|
||||
spec_id: str,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> dict:
|
||||
"""Update task metadata (title, description, and/or status).
|
||||
|
||||
Args:
|
||||
spec_id: The spec directory name (e.g., "001-my-feature")
|
||||
title: New title (optional)
|
||||
description: New description (optional)
|
||||
status: New status (optional) - must be a valid status
|
||||
"""
|
||||
try:
|
||||
service = _get_task_service()
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
if status is not None and status not in VALID_STATUSES:
|
||||
return {"error": f"Invalid status '{status}'. Valid: {sorted(VALID_STATUSES)}"}
|
||||
|
||||
task = service.update_task(
|
||||
spec_id, title=title, description=description, status=status
|
||||
)
|
||||
if task is None:
|
||||
return {"error": f"Task '{spec_id}' not found or invalid update"}
|
||||
|
||||
return {"success": True, "task": task}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def task_delete(spec_id: str) -> dict:
|
||||
"""Delete a task by removing its spec directory.
|
||||
|
||||
WARNING: This permanently deletes the spec directory and all its contents.
|
||||
|
||||
Args:
|
||||
spec_id: The spec directory name (e.g., "001-my-feature")
|
||||
"""
|
||||
try:
|
||||
service = _get_task_service()
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
deleted = service.delete_task(spec_id)
|
||||
if not deleted:
|
||||
return {"error": f"Task '{spec_id}' not found"}
|
||||
|
||||
return {"success": True, "message": f"Task '{spec_id}' deleted"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def task_update_status(spec_id: str, status: str) -> dict:
|
||||
"""Update just the status field of a task.
|
||||
|
||||
Args:
|
||||
spec_id: The spec directory name (e.g., "001-my-feature")
|
||||
status: New status value. Valid statuses: pending, spec_creating,
|
||||
planning, in_progress, qa_review, qa_fixing, human_review,
|
||||
done, failed, cancelled
|
||||
"""
|
||||
try:
|
||||
service = _get_task_service()
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
if status not in VALID_STATUSES:
|
||||
return {"error": f"Invalid status '{status}'. Valid: {sorted(VALID_STATUSES)}"}
|
||||
|
||||
task = service.update_status(spec_id, status)
|
||||
if task is None:
|
||||
return {"error": f"Task '{spec_id}' not found"}
|
||||
|
||||
return {"success": True, "task": task}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Workspace Tools
|
||||
================
|
||||
|
||||
MCP tools for managing git worktrees: list, diff, merge, discard, and PR creation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from mcp_server.config import get_project_dir
|
||||
from mcp_server.operations import OperationStatus, tracker
|
||||
from mcp_server.server import mcp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def workspace_list() -> dict:
|
||||
"""List all active git worktrees for the project.
|
||||
|
||||
Each spec gets its own isolated worktree. This shows all active
|
||||
worktrees with their branch, change stats, and age.
|
||||
|
||||
Returns:
|
||||
List of worktrees with branch, stats, and age information
|
||||
"""
|
||||
from mcp_server.services.workspace_service import WorkspaceService
|
||||
|
||||
service = WorkspaceService(get_project_dir())
|
||||
return service.list_worktrees()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def workspace_diff(spec_id: str) -> dict:
|
||||
"""Get the git diff for a spec's worktree.
|
||||
|
||||
Shows all changes made in the spec's branch compared to the base branch,
|
||||
including a file-level summary and the full diff content.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix (e.g. '001' or '001-my-feature')
|
||||
|
||||
Returns:
|
||||
Changed files summary and full diff content
|
||||
"""
|
||||
from mcp_server.services.workspace_service import WorkspaceService
|
||||
|
||||
service = WorkspaceService(get_project_dir())
|
||||
return service.get_diff(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workspace_merge(spec_id: str, strategy: str = "auto") -> dict:
|
||||
"""Merge a spec's worktree changes back to the main branch.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
strategy: 'auto' for standard git merge, 'no-commit' to stage without committing
|
||||
|
||||
Returns:
|
||||
Whether the merge was successful
|
||||
"""
|
||||
from mcp_server.services.workspace_service import WorkspaceService
|
||||
|
||||
service = WorkspaceService(get_project_dir())
|
||||
return await service.merge(spec_id, strategy=strategy)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def workspace_discard(spec_id: str) -> dict:
|
||||
"""Discard a spec's worktree and its branch.
|
||||
|
||||
Permanently removes the worktree directory and deletes the associated
|
||||
git branch. This cannot be undone.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
|
||||
Returns:
|
||||
Whether the discard was successful
|
||||
"""
|
||||
from mcp_server.services.workspace_service import WorkspaceService
|
||||
|
||||
service = WorkspaceService(get_project_dir())
|
||||
return service.discard(spec_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def workspace_create_pr(
|
||||
spec_id: str,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a pull request from a spec's worktree branch.
|
||||
|
||||
Pushes the branch to origin and creates a PR/MR on the detected
|
||||
git hosting provider (GitHub or GitLab). This is a long-running operation.
|
||||
|
||||
Args:
|
||||
spec_id: Spec folder name or prefix
|
||||
title: PR title (defaults to spec name)
|
||||
body: PR body (defaults to spec summary)
|
||||
|
||||
Returns:
|
||||
An operation_id to poll with operation_get_status() for progress
|
||||
"""
|
||||
op = tracker.create("create_pr", f"Creating PR for: {spec_id}")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
from mcp_server.services.workspace_service import WorkspaceService
|
||||
|
||||
service = WorkspaceService(get_project_dir())
|
||||
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=20,
|
||||
message="Pushing branch and creating PR...",
|
||||
)
|
||||
|
||||
result = await service.create_pr(
|
||||
spec_id=spec_id,
|
||||
title=title,
|
||||
body=body,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
pr_url = result.get("pr_url", "")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message=f"PR created: {pr_url}" if pr_url else "PR created",
|
||||
result=result,
|
||||
)
|
||||
else:
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=result.get("error", "PR creation failed"),
|
||||
result=result,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("workspace_create_pr operation failed")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
op._task = asyncio.create_task(_run())
|
||||
return {
|
||||
"operation_id": op.id,
|
||||
"message": "PR creation started. Poll operation_get_status() for progress.",
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Tests for mcp_server/config.py
|
||||
==============================
|
||||
|
||||
Tests project configuration initialization, directory resolution,
|
||||
project index loading, and initialization state checks.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import json
|
||||
|
||||
import mcp_server.config as config
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
"""Tests for config.initialize()."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset global state before each test."""
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
def test_initialize_valid_project_dir(self, tmp_path):
|
||||
"""Initialize with a valid directory sets the project dir."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.get_project_dir() == tmp_path.resolve()
|
||||
|
||||
def test_initialize_missing_dir_raises_value_error(self, tmp_path):
|
||||
"""Initialize with nonexistent directory raises ValueError."""
|
||||
import pytest
|
||||
|
||||
bad_path = tmp_path / "nonexistent"
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
config.initialize(bad_path)
|
||||
|
||||
def test_initialize_with_string_path(self, tmp_path):
|
||||
"""Initialize accepts a string path and resolves it."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(str(tmp_path))
|
||||
assert config.get_project_dir() == tmp_path.resolve()
|
||||
|
||||
def test_initialize_finds_auto_claude_dir(self, tmp_path):
|
||||
"""Initialize locates the .auto-claude directory."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.get_auto_claude_dir() == ac_dir
|
||||
|
||||
def test_initialize_finds_legacy_auto_claude_dir(self, tmp_path):
|
||||
"""Initialize falls back to legacy auto-claude (no dot prefix)."""
|
||||
legacy_dir = tmp_path / "auto-claude"
|
||||
legacy_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.get_auto_claude_dir() == legacy_dir
|
||||
|
||||
def test_initialize_warns_when_no_auto_claude_dir(self, tmp_path):
|
||||
"""Initialize still works but warns when no .auto-claude dir exists."""
|
||||
config.initialize(tmp_path)
|
||||
# Should point to the expected (but missing) .auto-claude path
|
||||
assert config._auto_claude_dir == tmp_path / ".auto-claude"
|
||||
|
||||
|
||||
class TestGetProjectDir:
|
||||
"""Tests for config.get_project_dir()."""
|
||||
|
||||
def setup_method(self):
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
def test_get_project_dir_before_initialize_raises(self):
|
||||
"""get_project_dir raises RuntimeError when not initialized."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
config.get_project_dir()
|
||||
|
||||
def test_get_project_dir_after_initialize(self, tmp_path):
|
||||
"""get_project_dir returns the resolved project path."""
|
||||
(tmp_path / ".auto-claude").mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.get_project_dir() == tmp_path.resolve()
|
||||
|
||||
|
||||
class TestGetAutoClaudeDir:
|
||||
"""Tests for config.get_auto_claude_dir()."""
|
||||
|
||||
def setup_method(self):
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
def test_get_auto_claude_dir_before_initialize_raises(self):
|
||||
"""get_auto_claude_dir raises RuntimeError when not initialized."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
config.get_auto_claude_dir()
|
||||
|
||||
def test_get_auto_claude_dir_after_initialize(self, tmp_path):
|
||||
"""get_auto_claude_dir returns the .auto-claude path."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.get_auto_claude_dir() == ac_dir
|
||||
|
||||
|
||||
class TestGetSpecsDir:
|
||||
"""Tests for config.get_specs_dir()."""
|
||||
|
||||
def setup_method(self):
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
def test_get_specs_dir_returns_specs_subdir(self, tmp_path):
|
||||
"""get_specs_dir returns .auto-claude/specs path."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.get_specs_dir() == ac_dir / "specs"
|
||||
|
||||
|
||||
class TestGetProjectIndex:
|
||||
"""Tests for config.get_project_index()."""
|
||||
|
||||
def setup_method(self):
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
def test_get_project_index_loads_valid_json(self, tmp_path):
|
||||
"""get_project_index loads and returns valid JSON data."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
index_data = {"files": ["main.py"], "language": "python"}
|
||||
(ac_dir / "project_index.json").write_text(json.dumps(index_data))
|
||||
config.initialize(tmp_path)
|
||||
result = config.get_project_index()
|
||||
assert result == index_data
|
||||
|
||||
def test_get_project_index_returns_empty_on_missing_file(self, tmp_path):
|
||||
"""get_project_index returns empty dict when file doesn't exist."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
result = config.get_project_index()
|
||||
assert result == {}
|
||||
|
||||
def test_get_project_index_returns_empty_on_invalid_json(self, tmp_path):
|
||||
"""get_project_index returns empty dict on malformed JSON."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
(ac_dir / "project_index.json").write_text("{invalid json!!")
|
||||
config.initialize(tmp_path)
|
||||
result = config.get_project_index()
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestIsInitialized:
|
||||
"""Tests for config.is_initialized()."""
|
||||
|
||||
def setup_method(self):
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
def test_is_initialized_returns_false_before_init(self):
|
||||
"""is_initialized returns False when config hasn't been initialized."""
|
||||
assert config.is_initialized() is False
|
||||
|
||||
def test_is_initialized_returns_true_with_existing_dir(self, tmp_path):
|
||||
"""is_initialized returns True when .auto-claude directory exists."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
config.initialize(tmp_path)
|
||||
assert config.is_initialized() is True
|
||||
|
||||
def test_is_initialized_returns_false_when_dir_missing(self, tmp_path):
|
||||
"""is_initialized returns False when .auto-claude dir doesn't exist on disk."""
|
||||
config.initialize(tmp_path)
|
||||
# _auto_claude_dir is set but directory doesn't exist on disk
|
||||
assert config.is_initialized() is False
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
Tests for mcp_server/services/execution_service.py
|
||||
====================================================
|
||||
|
||||
Tests the ExecutionService singleton, event parsing, build lifecycle,
|
||||
progress tracking, and log retrieval.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import mcp_server.services.execution_service as exec_mod
|
||||
from mcp_server.services.execution_service import ExecutionService, get_execution_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
"""Reset the module-level singleton between tests."""
|
||||
exec_mod._instance = None
|
||||
yield
|
||||
exec_mod._instance = None
|
||||
|
||||
|
||||
class TestGetExecutionService:
|
||||
"""Tests for the singleton factory function."""
|
||||
|
||||
def test_returns_instance(self, tmp_path):
|
||||
"""get_execution_service creates an ExecutionService."""
|
||||
svc = get_execution_service(tmp_path)
|
||||
assert isinstance(svc, ExecutionService)
|
||||
assert svc.project_dir == tmp_path
|
||||
|
||||
def test_returns_same_instance(self, tmp_path):
|
||||
"""Repeated calls with the same path return the cached singleton."""
|
||||
svc1 = get_execution_service(tmp_path)
|
||||
svc2 = get_execution_service(tmp_path)
|
||||
assert svc1 is svc2
|
||||
|
||||
def test_recreates_on_different_project(self, tmp_path):
|
||||
"""Switching project_dir creates a fresh instance."""
|
||||
dir_a = tmp_path / "a"
|
||||
dir_b = tmp_path / "b"
|
||||
dir_a.mkdir()
|
||||
dir_b.mkdir()
|
||||
|
||||
svc_a = get_execution_service(dir_a)
|
||||
svc_b = get_execution_service(dir_b)
|
||||
assert svc_a is not svc_b
|
||||
assert svc_b.project_dir == dir_b
|
||||
|
||||
|
||||
class TestParseEvent:
|
||||
"""Tests for ExecutionService.parse_event()."""
|
||||
|
||||
def test_parses_valid_event_line(self, tmp_path):
|
||||
"""parse_event returns dict for a valid __TASK_EVENT__: JSON line."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
payload = {"type": "subtask_complete", "index": 1}
|
||||
line = f"__TASK_EVENT__:{json.dumps(payload)}"
|
||||
result = svc.parse_event(line)
|
||||
assert result == payload
|
||||
|
||||
def test_returns_none_for_non_event_line(self, tmp_path):
|
||||
"""parse_event returns None for a regular log line."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc.parse_event("INFO: Starting build...") is None
|
||||
|
||||
def test_returns_none_for_empty_string(self, tmp_path):
|
||||
"""parse_event returns None for empty input."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc.parse_event("") is None
|
||||
|
||||
def test_returns_none_for_malformed_json(self, tmp_path):
|
||||
"""parse_event returns None when JSON after prefix is invalid."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc.parse_event("__TASK_EVENT__:{not valid json}") is None
|
||||
|
||||
def test_parses_nested_json(self, tmp_path):
|
||||
"""parse_event handles nested JSON structures."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
payload = {"type": "progress", "data": {"completed": [1, 2], "total": 5}}
|
||||
line = f"__TASK_EVENT__:{json.dumps(payload)}"
|
||||
assert svc.parse_event(line) == payload
|
||||
|
||||
|
||||
class TestStopBuild:
|
||||
"""Tests for ExecutionService.stop_build()."""
|
||||
|
||||
def test_stop_unknown_spec_returns_error(self, tmp_path):
|
||||
"""stop_build returns error dict for a spec not being tracked."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.stop_build("999-missing")
|
||||
assert result["success"] is False
|
||||
assert "No build found" in result["error"]
|
||||
|
||||
def test_stop_already_finished_process(self, tmp_path):
|
||||
"""stop_build returns error when process already exited."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = 0
|
||||
svc._processes["001-feat"] = mock_proc
|
||||
result = svc.stop_build("001-feat")
|
||||
assert result["success"] is False
|
||||
assert "already finished" in result["error"]
|
||||
|
||||
def test_stop_running_process(self, tmp_path):
|
||||
"""stop_build terminates a running process and returns success."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = None # still running
|
||||
svc._processes["001-feat"] = mock_proc
|
||||
result = svc.stop_build("001-feat")
|
||||
assert result["success"] is True
|
||||
mock_proc.terminate.assert_called_once()
|
||||
|
||||
def test_stop_process_lookup_error(self, tmp_path):
|
||||
"""stop_build handles ProcessLookupError (race condition)."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = None
|
||||
mock_proc.terminate.side_effect = ProcessLookupError
|
||||
svc._processes["001-feat"] = mock_proc
|
||||
result = svc.stop_build("001-feat")
|
||||
assert result["success"] is False
|
||||
assert "already exited" in result["error"]
|
||||
|
||||
|
||||
class TestGetProgress:
|
||||
"""Tests for ExecutionService.get_progress()."""
|
||||
|
||||
def test_progress_with_running_process(self, tmp_path):
|
||||
"""get_progress reports running status and event info."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = None
|
||||
svc._processes["001"] = mock_proc
|
||||
svc._logs["001"] = ["line1", "line2"]
|
||||
svc._events["001"] = [{"type": "start"}, {"type": "subtask_complete"}]
|
||||
|
||||
result = svc.get_progress("001")
|
||||
assert result["running"] is True
|
||||
assert result["event_count"] == 2
|
||||
assert result["latest_event"] == {"type": "subtask_complete"}
|
||||
assert result["log_lines"] == 2
|
||||
|
||||
def test_progress_with_finished_process(self, tmp_path):
|
||||
"""get_progress reports finished status with exit code."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = 1
|
||||
svc._processes["001"] = mock_proc
|
||||
svc._events["001"] = []
|
||||
svc._logs["001"] = []
|
||||
|
||||
result = svc.get_progress("001")
|
||||
assert result["running"] is False
|
||||
assert result["exit_code"] == 1
|
||||
|
||||
def test_progress_falls_back_to_disk(self, tmp_path):
|
||||
"""get_progress checks disk state when no process is tracked."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
plan = {"subtasks": [{"status": "completed"}, {"status": "pending"}]}
|
||||
(specs_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("001-feat")
|
||||
assert result["running"] is False
|
||||
assert result["status"] == "building"
|
||||
assert result["subtasks_completed"] == 1
|
||||
assert result["subtasks_total"] == 2
|
||||
|
||||
def test_progress_disk_no_plan(self, tmp_path):
|
||||
"""get_progress returns no_plan when spec dir has no plan file."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "002-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("002-feat")
|
||||
assert result["status"] == "no_plan"
|
||||
|
||||
def test_progress_disk_spec_not_found(self, tmp_path):
|
||||
"""get_progress returns error when spec directory doesn't exist."""
|
||||
(tmp_path / ".auto-claude" / "specs").mkdir(parents=True)
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("999-missing")
|
||||
assert "error" in result
|
||||
|
||||
def test_progress_disk_qa_approved(self, tmp_path):
|
||||
"""get_progress identifies qa_approved status from plan."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
plan = {
|
||||
"subtasks": [{"status": "completed"}],
|
||||
"qa_signoff": {"status": "approved"},
|
||||
}
|
||||
(specs_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("001-feat")
|
||||
assert result["status"] == "qa_approved"
|
||||
|
||||
def test_progress_disk_qa_rejected(self, tmp_path):
|
||||
"""get_progress identifies qa_rejected status."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
plan = {
|
||||
"subtasks": [{"status": "completed"}],
|
||||
"qa_signoff": {"status": "rejected"},
|
||||
}
|
||||
(specs_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("001-feat")
|
||||
assert result["status"] == "qa_rejected"
|
||||
|
||||
def test_progress_disk_build_complete(self, tmp_path):
|
||||
"""get_progress shows build_complete when all subtasks done, no QA."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
plan = {"subtasks": [{"status": "completed"}, {"status": "completed"}]}
|
||||
(specs_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("001-feat")
|
||||
assert result["status"] == "build_complete"
|
||||
|
||||
def test_progress_disk_not_started(self, tmp_path):
|
||||
"""get_progress shows not_started when no subtasks completed."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
plan = {"subtasks": [{"status": "pending"}, {"status": "pending"}]}
|
||||
(specs_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("001-feat")
|
||||
assert result["status"] == "not_started"
|
||||
|
||||
def test_progress_disk_invalid_plan_json(self, tmp_path):
|
||||
"""get_progress handles corrupt plan file gracefully."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "implementation_plan.json").write_text("not json!")
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_progress("001-feat")
|
||||
assert result["status"] == "error"
|
||||
|
||||
|
||||
class TestGetLogs:
|
||||
"""Tests for ExecutionService.get_logs()."""
|
||||
|
||||
def test_get_logs_from_memory(self, tmp_path):
|
||||
"""get_logs returns in-memory log lines."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
svc._logs["001"] = [f"line-{i}" for i in range(100)]
|
||||
result = svc.get_logs("001", tail=10)
|
||||
assert result["total_lines"] == 100
|
||||
assert len(result["lines"]) == 10
|
||||
assert result["lines"][0] == "line-90"
|
||||
|
||||
def test_get_logs_falls_back_to_disk(self, tmp_path):
|
||||
"""get_logs reads disk logs when in-memory is empty."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
log_lines = [f'{{"event": {i}}}' for i in range(20)]
|
||||
(specs_dir / "task_log.jsonl").write_text("\n".join(log_lines))
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_logs("001-feat", tail=5)
|
||||
assert result["total_lines"] == 20
|
||||
assert len(result["lines"]) == 5
|
||||
|
||||
def test_get_logs_disk_no_log_file(self, tmp_path):
|
||||
"""get_logs returns empty when no log file exists on disk."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / "001-feat"
|
||||
specs_dir.mkdir(parents=True)
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_logs("001-feat")
|
||||
assert result["lines"] == []
|
||||
assert "No build logs" in result.get("message", "")
|
||||
|
||||
def test_get_logs_disk_spec_not_found(self, tmp_path):
|
||||
"""get_logs returns error when spec doesn't exist."""
|
||||
(tmp_path / ".auto-claude" / "specs").mkdir(parents=True)
|
||||
svc = ExecutionService(tmp_path)
|
||||
result = svc.get_logs("999-missing")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestResolveSpecDir:
|
||||
"""Tests for ExecutionService._resolve_spec_dir()."""
|
||||
|
||||
def test_exact_match(self, tmp_path):
|
||||
"""_resolve_spec_dir finds exact directory match."""
|
||||
specs_dir = tmp_path / "specs"
|
||||
target = specs_dir / "001-feature"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc._resolve_spec_dir(specs_dir, "001-feature") == target
|
||||
|
||||
def test_prefix_match(self, tmp_path):
|
||||
"""_resolve_spec_dir finds directory by prefix."""
|
||||
specs_dir = tmp_path / "specs"
|
||||
target = specs_dir / "001-my-feature"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc._resolve_spec_dir(specs_dir, "001") == target
|
||||
|
||||
def test_no_match(self, tmp_path):
|
||||
"""_resolve_spec_dir returns None when no directory matches."""
|
||||
specs_dir = tmp_path / "specs"
|
||||
specs_dir.mkdir()
|
||||
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc._resolve_spec_dir(specs_dir, "999") is None
|
||||
|
||||
def test_no_specs_dir(self, tmp_path):
|
||||
"""_resolve_spec_dir returns None when specs dir doesn't exist."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
assert svc._resolve_spec_dir(tmp_path / "nonexistent", "001") is None
|
||||
|
||||
|
||||
class TestStartBuild:
|
||||
"""Tests for ExecutionService.start_build()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_build_raises_if_already_running(self, tmp_path):
|
||||
"""start_build raises RuntimeError if build already in progress."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = None # still running
|
||||
svc._processes["001"] = mock_proc
|
||||
|
||||
with pytest.raises(RuntimeError, match="already running"):
|
||||
await svc.start_build("001")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_build_raises_if_run_py_missing(self, tmp_path, monkeypatch):
|
||||
"""start_build raises FileNotFoundError when run.py not found."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
# Patch Path.exists to return False for the run.py check
|
||||
original_exists = Path.exists
|
||||
def fake_exists(self):
|
||||
if self.name == "run.py":
|
||||
return False
|
||||
return original_exists(self)
|
||||
monkeypatch.setattr(Path, "exists", fake_exists)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="run.py not found"):
|
||||
await svc.start_build("001")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_build_allows_restart_after_completion(self, tmp_path, monkeypatch):
|
||||
"""start_build allows restarting after a previous process completed."""
|
||||
svc = ExecutionService(tmp_path)
|
||||
old_proc = MagicMock()
|
||||
old_proc.returncode = 0 # already finished
|
||||
svc._processes["001"] = old_proc
|
||||
|
||||
# Patch run.py to not exist so we get FileNotFoundError
|
||||
# (proving it got past the "already running" check)
|
||||
original_exists = Path.exists
|
||||
def fake_exists(self):
|
||||
if self.name == "run.py":
|
||||
return False
|
||||
return original_exists(self)
|
||||
monkeypatch.setattr(Path, "exists", fake_exists)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="run.py not found"):
|
||||
await svc.start_build("001")
|
||||
@@ -0,0 +1,889 @@
|
||||
"""
|
||||
Tests for MCP Feature Services: Insights, Roadmap, Ideation, Memory
|
||||
=====================================================================
|
||||
|
||||
Combined tests for InsightsService, RoadmapService, IdeationService,
|
||||
and MemoryService (singleton pattern).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Setup: add backend to path and pre-mock SDK
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.services.insights_service import InsightsService
|
||||
from mcp_server.services.roadmap_service import RoadmapService
|
||||
from mcp_server.services.ideation_service import IdeationService, VALID_IDEATION_TYPES
|
||||
import mcp_server.services.memory_service as memory_mod
|
||||
from mcp_server.services.memory_service import MemoryService, get_memory_service
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a temp project dir with .auto-claude structure."""
|
||||
ac = tmp_path / ".auto-claude"
|
||||
ac.mkdir()
|
||||
(ac / "ideation").mkdir()
|
||||
(ac / "roadmap").mkdir()
|
||||
(ac / "github").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_memory_singleton():
|
||||
"""Reset the MemoryService singleton between every test."""
|
||||
memory_mod._instance = None
|
||||
yield
|
||||
memory_mod._instance = None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# InsightsService Tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInsightsServiceAsk:
|
||||
"""Tests for InsightsService.ask() - AI codebase Q&A."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_ask_captures_stdout(self, project_dir):
|
||||
"""ask() captures stdout from run_with_sdk and returns it."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
mock_run = AsyncMock()
|
||||
|
||||
async def _fake_run(**kwargs):
|
||||
print("The answer is 42.")
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.insights_runner": MagicMock(
|
||||
run_with_sdk=_fake_run
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.ask("What is the meaning?")
|
||||
|
||||
assert result["success"] is True
|
||||
assert "42" in result["response"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_ask_parses_task_suggestions(self, project_dir):
|
||||
"""ask() extracts __TASK_SUGGESTION__ lines from output."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
suggestion = json.dumps({"title": "Add tests", "priority": "high"})
|
||||
|
||||
async def _fake_run(**kwargs):
|
||||
print("Some response text")
|
||||
print(f"__TASK_SUGGESTION__:{suggestion}")
|
||||
print("More text")
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.insights_runner": MagicMock(
|
||||
run_with_sdk=_fake_run
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.ask("Suggest improvements")
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["task_suggestions"]) == 1
|
||||
assert result["task_suggestions"][0]["title"] == "Add tests"
|
||||
# Task suggestion lines should not appear in response
|
||||
assert "__TASK_SUGGESTION__" not in result["response"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_ask_strips_tool_markers(self, project_dir):
|
||||
"""ask() removes __TOOL_START__ and __TOOL_END__ markers."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
async def _fake_run(**kwargs):
|
||||
print("__TOOL_START__:search")
|
||||
print("Real content here")
|
||||
print("__TOOL_END__:search")
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.insights_runner": MagicMock(
|
||||
run_with_sdk=_fake_run
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.ask("Question")
|
||||
|
||||
assert "__TOOL_START__" not in result["response"]
|
||||
assert "__TOOL_END__" not in result["response"]
|
||||
assert "Real content here" in result["response"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_ask_passes_params_correctly(self, project_dir):
|
||||
"""ask() forwards all parameters to run_with_sdk."""
|
||||
service = InsightsService(project_dir)
|
||||
captured_kwargs = {}
|
||||
|
||||
async def _fake_run(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
print("ok")
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.insights_runner": MagicMock(
|
||||
run_with_sdk=_fake_run
|
||||
)
|
||||
},
|
||||
):
|
||||
await service.ask(
|
||||
"Q",
|
||||
history=[{"role": "user", "content": "hi"}],
|
||||
model="opus",
|
||||
thinking_level="high",
|
||||
)
|
||||
|
||||
assert captured_kwargs["project_dir"] == str(project_dir)
|
||||
assert captured_kwargs["message"] == "Q"
|
||||
assert captured_kwargs["model"] == "opus"
|
||||
assert captured_kwargs["thinking_level"] == "high"
|
||||
assert len(captured_kwargs["history"]) == 1
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_ask_import_error(self, project_dir):
|
||||
"""When insights runner is unavailable, returns error."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
with patch.dict("sys.modules", {"runners.insights_runner": None}):
|
||||
result = await service.ask("Question")
|
||||
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_ask_exception(self, project_dir):
|
||||
"""When run_with_sdk raises, returns error."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
async def _boom(**kwargs):
|
||||
raise RuntimeError("Model overloaded")
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.insights_runner": MagicMock(
|
||||
run_with_sdk=_boom
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.ask("Question")
|
||||
|
||||
assert "error" in result
|
||||
assert "Model overloaded" in result["error"]
|
||||
|
||||
|
||||
class TestInsightsServiceSuggestTasks:
|
||||
"""Tests for InsightsService.suggest_tasks() - reads ideation data."""
|
||||
|
||||
def test_suggest_tasks_with_ideation_data(self, project_dir):
|
||||
"""When ideation.json exists, returns task suggestions."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
ideation_data = {
|
||||
"ideas": [
|
||||
{
|
||||
"title": "Add caching",
|
||||
"description": "Implement Redis cache",
|
||||
"type": "performance",
|
||||
"impact": "high",
|
||||
"effort": "medium",
|
||||
},
|
||||
{
|
||||
"title": "Fix typos",
|
||||
"description": "Fix documentation typos",
|
||||
"type": "docs",
|
||||
"impact": "low",
|
||||
"effort": "low",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text(json.dumps(ideation_data))
|
||||
|
||||
result = service.suggest_tasks()
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["suggestions"]) == 2
|
||||
assert result["suggestions"][0]["title"] == "Add caching"
|
||||
assert result["suggestions"][0]["category"] == "performance"
|
||||
assert result["suggestions"][1]["impact"] == "low"
|
||||
|
||||
def test_suggest_tasks_no_ideation_data(self, project_dir):
|
||||
"""When ideation.json doesn't exist, returns empty with message."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
result = service.suggest_tasks()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["suggestions"] == []
|
||||
assert "No ideation data" in result["message"]
|
||||
|
||||
def test_suggest_tasks_limits_to_10(self, project_dir):
|
||||
"""At most 10 suggestions are returned."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
ideation_data = {
|
||||
"ideas": [
|
||||
{"title": f"Idea {i}", "description": f"Desc {i}"}
|
||||
for i in range(20)
|
||||
]
|
||||
}
|
||||
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text(json.dumps(ideation_data))
|
||||
|
||||
result = service.suggest_tasks()
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["suggestions"]) == 10
|
||||
|
||||
def test_suggest_tasks_corrupt_json(self, project_dir):
|
||||
"""When ideation.json is corrupt, returns error."""
|
||||
service = InsightsService(project_dir)
|
||||
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text("not valid json{{{")
|
||||
|
||||
result = service.suggest_tasks()
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RoadmapService Tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestRoadmapServiceGetRoadmap:
|
||||
"""Tests for RoadmapService.get_roadmap() - reads roadmap from disk."""
|
||||
|
||||
def test_get_roadmap_exists(self, project_dir):
|
||||
"""When roadmap.json exists, returns it."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
roadmap_data = {
|
||||
"vision": "World domination",
|
||||
"phases": [{"name": "Phase 1", "features": []}],
|
||||
}
|
||||
|
||||
roadmap_file = project_dir / ".auto-claude" / "roadmap" / "roadmap.json"
|
||||
roadmap_file.write_text(json.dumps(roadmap_data))
|
||||
|
||||
result = service.get_roadmap()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["vision"] == "World domination"
|
||||
|
||||
def test_get_roadmap_not_found(self, project_dir):
|
||||
"""When roadmap.json does not exist, returns None with message."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
result = service.get_roadmap()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"] is None
|
||||
assert "No roadmap generated" in result["message"]
|
||||
|
||||
def test_get_roadmap_corrupt_json(self, project_dir):
|
||||
"""When roadmap.json is corrupt, returns error."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
roadmap_file = project_dir / ".auto-claude" / "roadmap" / "roadmap.json"
|
||||
roadmap_file.write_text("{bad json")
|
||||
|
||||
result = service.get_roadmap()
|
||||
|
||||
assert "error" in result
|
||||
assert "Failed to load roadmap" in result["error"]
|
||||
|
||||
|
||||
class TestRoadmapServiceGenerate:
|
||||
"""Tests for RoadmapService.generate() - wraps RoadmapOrchestrator."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_success(self, project_dir):
|
||||
"""Successful generation calls orchestrator and returns roadmap."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(return_value=True)
|
||||
|
||||
# Write roadmap data that get_roadmap() will read after generation
|
||||
roadmap_data = {"vision": "Built", "phases": []}
|
||||
roadmap_file = project_dir / ".auto-claude" / "roadmap" / "roadmap.json"
|
||||
roadmap_file.write_text(json.dumps(roadmap_data))
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.roadmap.orchestrator": MagicMock(
|
||||
RoadmapOrchestrator=MagicMock(return_value=mock_orchestrator)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.generate(refresh=True, model="opus")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["vision"] == "Built"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_failure(self, project_dir):
|
||||
"""When orchestrator returns False, returns error."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(return_value=False)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.roadmap.orchestrator": MagicMock(
|
||||
RoadmapOrchestrator=MagicMock(return_value=mock_orchestrator)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.generate()
|
||||
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_import_error(self, project_dir):
|
||||
"""When roadmap module is unavailable, returns error."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
with patch.dict("sys.modules", {"runners.roadmap.orchestrator": None}):
|
||||
result = await service.generate()
|
||||
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_exception(self, project_dir):
|
||||
"""When generate raises, returns error."""
|
||||
service = RoadmapService(project_dir)
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.roadmap.orchestrator": MagicMock(
|
||||
RoadmapOrchestrator=MagicMock(return_value=mock_orchestrator)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.generate()
|
||||
|
||||
assert "error" in result
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# IdeationService Tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestIdeationServiceGetIdeation:
|
||||
"""Tests for IdeationService.get_ideation() - reads from disk."""
|
||||
|
||||
def test_get_ideation_exists(self, project_dir):
|
||||
"""When ideation.json exists, returns it."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
ideation_data = {
|
||||
"ideas": [{"title": "Cache everything", "type": "performance"}]
|
||||
}
|
||||
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text(json.dumps(ideation_data))
|
||||
|
||||
result = service.get_ideation()
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]["ideas"]) == 1
|
||||
|
||||
def test_get_ideation_not_found(self, project_dir):
|
||||
"""When ideation.json does not exist, returns None with message."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
result = service.get_ideation()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"] is None
|
||||
assert "No ideation data" in result["message"]
|
||||
|
||||
def test_get_ideation_corrupt_json(self, project_dir):
|
||||
"""When ideation.json is corrupt, returns error."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text("{{bad")
|
||||
|
||||
result = service.get_ideation()
|
||||
|
||||
assert "error" in result
|
||||
assert "Failed to load" in result["error"]
|
||||
|
||||
|
||||
class TestIdeationServiceGenerate:
|
||||
"""Tests for IdeationService.generate() - wraps IdeationOrchestrator."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_success(self, project_dir):
|
||||
"""Successful generation calls orchestrator and returns ideation."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(return_value=True)
|
||||
|
||||
# Write data that get_ideation will read
|
||||
ideation_data = {"ideas": [{"title": "Idea 1"}]}
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text(json.dumps(ideation_data))
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"ideation": MagicMock(
|
||||
IdeationOrchestrator=MagicMock(
|
||||
return_value=mock_orchestrator
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.generate(
|
||||
types=["low_hanging_fruit"], refresh=True, model="opus"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_invalid_types(self, project_dir):
|
||||
"""Invalid ideation types return error before calling orchestrator."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
# Mock the import so we get past it to the validation logic
|
||||
mock_module = MagicMock()
|
||||
with patch.dict("sys.modules", {"ideation": mock_module}):
|
||||
result = await service.generate(types=["invalid_type", "also_bad"])
|
||||
|
||||
assert "error" in result
|
||||
assert "Invalid ideation types" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_defaults_to_all_types(self, project_dir):
|
||||
"""When types is None, all valid types are used."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
captured_types = []
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(return_value=True)
|
||||
|
||||
# Write data for get_ideation
|
||||
ideation_file = project_dir / ".auto-claude" / "ideation" / "ideation.json"
|
||||
ideation_file.write_text(json.dumps({"ideas": []}))
|
||||
|
||||
mock_cls = MagicMock(return_value=mock_orchestrator)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"ideation": MagicMock(IdeationOrchestrator=mock_cls)},
|
||||
):
|
||||
await service.generate(types=None)
|
||||
|
||||
# Verify all valid types were passed
|
||||
call_kwargs = mock_cls.call_args[1]
|
||||
assert call_kwargs["enabled_types"] == VALID_IDEATION_TYPES
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_orchestrator_failure(self, project_dir):
|
||||
"""When orchestrator returns False, returns error."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(return_value=False)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"ideation": MagicMock(
|
||||
IdeationOrchestrator=MagicMock(
|
||||
return_value=mock_orchestrator
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.generate()
|
||||
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_import_error(self, project_dir):
|
||||
"""When ideation module is unavailable, returns error."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
with patch.dict("sys.modules", {"ideation": None}):
|
||||
result = await service.generate()
|
||||
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_generate_exception(self, project_dir):
|
||||
"""When generate raises, returns error."""
|
||||
service = IdeationService(project_dir)
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"ideation": MagicMock(
|
||||
IdeationOrchestrator=MagicMock(
|
||||
return_value=mock_orchestrator
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.generate()
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MemoryService Tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestMemoryServiceSingleton:
|
||||
"""Tests for the get_memory_service() singleton factory."""
|
||||
|
||||
def test_returns_same_instance(self, project_dir):
|
||||
"""get_memory_service returns the same instance for same dir."""
|
||||
svc1 = get_memory_service(project_dir)
|
||||
svc2 = get_memory_service(project_dir)
|
||||
assert svc1 is svc2
|
||||
|
||||
def test_creates_new_instance_for_different_dir(self, tmp_path):
|
||||
"""get_memory_service creates new instance when dir changes."""
|
||||
dir1 = tmp_path / "proj1"
|
||||
dir1.mkdir()
|
||||
dir2 = tmp_path / "proj2"
|
||||
dir2.mkdir()
|
||||
|
||||
svc1 = get_memory_service(dir1)
|
||||
svc2 = get_memory_service(dir2)
|
||||
assert svc1 is not svc2
|
||||
|
||||
def test_singleton_reset_between_tests(self):
|
||||
"""Verify autouse fixture resets the singleton."""
|
||||
assert memory_mod._instance is None
|
||||
|
||||
|
||||
class TestMemoryServiceDisabled:
|
||||
"""Tests for MemoryService when GRAPHITI_ENABLED is not set."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_search_disabled(self, project_dir):
|
||||
"""search() returns disabled message when Graphiti is off."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = await service.search("test query")
|
||||
|
||||
assert "error" in result
|
||||
assert "not enabled" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_add_episode_disabled(self, project_dir):
|
||||
"""add_episode() returns disabled message when Graphiti is off."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = await service.add_episode("some fact")
|
||||
|
||||
assert "error" in result
|
||||
assert "not enabled" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_get_recent_disabled(self, project_dir):
|
||||
"""get_recent() returns disabled message when Graphiti is off."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = await service.get_recent()
|
||||
|
||||
assert "error" in result
|
||||
assert "not enabled" in result["error"]
|
||||
|
||||
|
||||
class TestMemoryServiceEnabled:
|
||||
"""Tests for MemoryService when GRAPHITI_ENABLED=true."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_search_calls_get_relevant_context(self, project_dir):
|
||||
"""search() calls the public get_relevant_context() method."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.get_relevant_context = AsyncMock(
|
||||
return_value=["result1", "result2"]
|
||||
)
|
||||
mock_memory.initialize = AsyncMock(return_value=True)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
result = await service.search("test query", limit=5)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 2
|
||||
mock_memory.get_relevant_context.assert_awaited_once_with(
|
||||
query="test query", num_results=5
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_search_caches_memory_instance(self, project_dir):
|
||||
"""_get_memory() caches the memory instance after first call."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.get_relevant_context = AsyncMock(return_value=[])
|
||||
mock_memory.initialize = AsyncMock(return_value=True)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
await service.search("first")
|
||||
await service.search("second")
|
||||
|
||||
# GraphitiMemory should be instantiated only once
|
||||
assert mock_graphiti_mod.GraphitiMemory.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_add_episode_calls_save_session_insights(self, project_dir):
|
||||
"""add_episode() calls memory.save_session_insights."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.save_session_insights = AsyncMock(return_value=True)
|
||||
mock_memory.initialize = AsyncMock(return_value=True)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
result = await service.add_episode("Important fact", source="test")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_memory.save_session_insights.assert_awaited_once()
|
||||
call_kwargs = mock_memory.save_session_insights.call_args[1]
|
||||
assert call_kwargs["session_num"] == 0
|
||||
assert call_kwargs["insights"]["content"] == "Important fact"
|
||||
assert call_kwargs["insights"]["source"] == "test"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_add_episode_failure(self, project_dir):
|
||||
"""add_episode() returns error when save fails."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.save_session_insights = AsyncMock(return_value=False)
|
||||
mock_memory.initialize = AsyncMock(return_value=True)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
result = await service.add_episode("fact")
|
||||
|
||||
assert "error" in result
|
||||
assert "Failed to save" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_get_recent_uses_broad_search(self, project_dir):
|
||||
"""get_recent() uses get_relevant_context with a broad query."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.get_relevant_context = AsyncMock(return_value=["entry1"])
|
||||
mock_memory.initialize = AsyncMock(return_value=True)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
result = await service.get_recent(limit=5)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
mock_memory.get_relevant_context.assert_awaited_once_with(
|
||||
query="recent project activity and insights", num_results=5
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_get_memory_returns_none_on_init_failure(self, project_dir):
|
||||
"""_get_memory() returns None when initialize fails."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.initialize = AsyncMock(return_value=False)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
result = await service.search("query")
|
||||
|
||||
assert "error" in result
|
||||
assert "Could not initialize" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_get_memory_import_error(self, project_dir):
|
||||
"""_get_memory() returns None when graphiti import fails."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules", {"integrations.graphiti.memory": None}
|
||||
):
|
||||
result = await service.search("query")
|
||||
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_search_exception(self, project_dir):
|
||||
"""search() returns error when memory search raises."""
|
||||
service = MemoryService(project_dir)
|
||||
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.get_relevant_context = AsyncMock(
|
||||
side_effect=RuntimeError("connection lost")
|
||||
)
|
||||
mock_memory.initialize = AsyncMock(return_value=True)
|
||||
|
||||
mock_graphiti_mod = MagicMock()
|
||||
mock_graphiti_mod.GraphitiMemory = MagicMock(return_value=mock_memory)
|
||||
mock_graphiti_mod.GroupIdMode = MagicMock()
|
||||
mock_graphiti_mod.GroupIdMode.PROJECT = "project"
|
||||
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"integrations.graphiti.memory": mock_graphiti_mod},
|
||||
):
|
||||
result = await service.search("query")
|
||||
|
||||
assert "error" in result
|
||||
assert "connection lost" in result["error"]
|
||||
|
||||
|
||||
class TestMemoryServiceIsGraphitiEnabled:
|
||||
"""Tests for the _is_graphiti_enabled() helper."""
|
||||
|
||||
def test_enabled_true(self):
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "true"}):
|
||||
assert memory_mod._is_graphiti_enabled() is True
|
||||
|
||||
def test_enabled_one(self):
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "1"}):
|
||||
assert memory_mod._is_graphiti_enabled() is True
|
||||
|
||||
def test_enabled_TRUE_uppercase(self):
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "TRUE"}):
|
||||
assert memory_mod._is_graphiti_enabled() is True
|
||||
|
||||
def test_disabled_false(self):
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": "false"}):
|
||||
assert memory_mod._is_graphiti_enabled() is False
|
||||
|
||||
def test_disabled_empty(self):
|
||||
with patch.dict("os.environ", {"GRAPHITI_ENABLED": ""}):
|
||||
assert memory_mod._is_graphiti_enabled() is False
|
||||
|
||||
def test_disabled_not_set(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert memory_mod._is_graphiti_enabled() is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Valid Ideation Types Constant
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestIdeationConstants:
|
||||
"""Test that VALID_IDEATION_TYPES is correctly defined."""
|
||||
|
||||
def test_valid_types_list(self):
|
||||
assert "low_hanging_fruit" in VALID_IDEATION_TYPES
|
||||
assert "ui_ux_improvements" in VALID_IDEATION_TYPES
|
||||
assert "high_value_features" in VALID_IDEATION_TYPES
|
||||
assert len(VALID_IDEATION_TYPES) == 3
|
||||
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
Tests for MCP GitHub Service and Tools
|
||||
=======================================
|
||||
|
||||
Tests GitHubService (repo detection, issue listing, PR review, triage, auto-fix)
|
||||
and the github tool wrappers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Setup: add backend to path and pre-mock SDK
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.services.github_service import GitHubService
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a temp project dir with .auto-claude/github structure."""
|
||||
github_dir = tmp_path / ".auto-claude" / "github"
|
||||
github_dir.mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(project_dir):
|
||||
return GitHubService(project_dir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_repo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectRepo:
|
||||
"""Tests for _detect_repo() - parses git remote to owner/repo."""
|
||||
|
||||
def test_ssh_remote(self, service):
|
||||
"""SSH remote git@github.com:owner/repo.git is parsed correctly."""
|
||||
mock_result = MagicMock(returncode=0, stdout="git@github.com:owner/repo.git\n")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result == "owner/repo"
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args
|
||||
assert args[0][0] == ["git", "remote", "get-url", "origin"]
|
||||
|
||||
def test_ssh_remote_no_dot_git(self, service):
|
||||
"""SSH remote without .git suffix works."""
|
||||
mock_result = MagicMock(returncode=0, stdout="git@github.com:owner/repo\n")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result == "owner/repo"
|
||||
|
||||
def test_https_remote(self, service):
|
||||
"""HTTPS remote https://github.com/owner/repo.git is parsed correctly."""
|
||||
mock_result = MagicMock(
|
||||
returncode=0, stdout="https://github.com/owner/repo.git\n"
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result == "owner/repo"
|
||||
|
||||
def test_https_remote_no_dot_git(self, service):
|
||||
"""HTTPS remote without .git suffix works."""
|
||||
mock_result = MagicMock(
|
||||
returncode=0, stdout="https://github.com/owner/repo\n"
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result == "owner/repo"
|
||||
|
||||
def test_non_github_remote_returns_none(self, service):
|
||||
"""Non-GitHub remote (e.g. GitLab) returns None."""
|
||||
mock_result = MagicMock(
|
||||
returncode=0, stdout="https://gitlab.com/owner/repo.git\n"
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_git_command_fails_returns_none(self, service):
|
||||
"""If git command returns non-zero, returns None."""
|
||||
mock_result = MagicMock(returncode=1, stdout="", stderr="not a git repo")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_exception_returns_none(self, service):
|
||||
"""If subprocess raises, returns None."""
|
||||
with patch("subprocess.run", side_effect=OSError("git not found")):
|
||||
result = service._detect_repo()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_repo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetRepo:
|
||||
"""Tests for _get_repo() - resolves repo from explicit arg or auto-detect."""
|
||||
|
||||
def test_explicit_repo_returned_directly(self, service):
|
||||
"""When repo is provided, it is returned without auto-detection."""
|
||||
result = service._get_repo("my-org/my-repo")
|
||||
assert result == "my-org/my-repo"
|
||||
|
||||
def test_auto_detects_when_no_repo_provided(self, service):
|
||||
"""When repo is None, falls back to _detect_repo."""
|
||||
with patch.object(service, "_detect_repo", return_value="detected/repo"):
|
||||
result = service._get_repo(None)
|
||||
|
||||
assert result == "detected/repo"
|
||||
|
||||
def test_raises_when_no_repo_and_no_detection(self, service):
|
||||
"""When repo is None and detection fails, raises ValueError."""
|
||||
with patch.object(service, "_detect_repo", return_value=None):
|
||||
with pytest.raises(ValueError, match="Could not detect repository"):
|
||||
service._get_repo(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_issues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListIssues:
|
||||
"""Tests for list_issues() - calls gh CLI to list GitHub issues."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_list_issues_success(self, service):
|
||||
"""Successful gh issue list returns parsed JSON."""
|
||||
issues_json = '[{"number": 1, "title": "Bug"}]'
|
||||
mock_result = MagicMock(returncode=0, stdout=issues_json)
|
||||
with patch.object(service, "_get_repo", return_value="owner/repo"):
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = await service.list_issues(state="open", limit=10)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
assert result["issues"][0]["number"] == 1
|
||||
|
||||
# Verify gh CLI was called with correct args
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "gh" in cmd
|
||||
assert "issue" in cmd
|
||||
assert "list" in cmd
|
||||
assert "--repo" in cmd
|
||||
assert "owner/repo" in cmd
|
||||
assert "--state" in cmd
|
||||
assert "open" in cmd
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_list_issues_gh_failure(self, service):
|
||||
"""When gh CLI fails, returns error dict."""
|
||||
mock_result = MagicMock(
|
||||
returncode=1, stdout="", stderr="authentication required"
|
||||
)
|
||||
with patch.object(service, "_get_repo", return_value="owner/repo"):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = await service.list_issues()
|
||||
|
||||
assert "error" in result
|
||||
assert "gh CLI failed" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_list_issues_exception(self, service):
|
||||
"""When an exception occurs, returns error dict."""
|
||||
with patch.object(
|
||||
service, "_get_repo", side_effect=ValueError("no repo")
|
||||
):
|
||||
result = await service.list_issues()
|
||||
|
||||
assert "error" in result
|
||||
assert "no repo" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_list_issues_with_explicit_repo(self, service):
|
||||
"""Repo argument is forwarded to _get_repo."""
|
||||
issues_json = "[]"
|
||||
mock_result = MagicMock(returncode=0, stdout=issues_json)
|
||||
with patch.object(
|
||||
service, "_get_repo", return_value="explicit/repo"
|
||||
) as mock_get:
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
await service.list_issues(repo="explicit/repo")
|
||||
|
||||
mock_get.assert_called_once_with("explicit/repo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# review_pr
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReviewPR:
|
||||
"""Tests for review_pr() - wraps GitHubOrchestrator.review_pr."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_review_pr_success(self, service):
|
||||
"""Successful review returns serialized result."""
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.to_dict.return_value = {"verdict": "approve", "findings": []}
|
||||
mock_orchestrator.review_pr = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(service, "_get_repo", return_value="owner/repo"):
|
||||
with patch.object(service, "_create_config") as mock_config:
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.orchestrator": MagicMock(
|
||||
GitHubOrchestrator=MagicMock(
|
||||
return_value=mock_orchestrator
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.review_pr(42)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["verdict"] == "approve"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_review_pr_import_error(self, service):
|
||||
"""When GitHubOrchestrator is not available, returns error."""
|
||||
with patch.object(service, "_get_repo", return_value="owner/repo"):
|
||||
with patch.dict("sys.modules", {"runners.github.orchestrator": None}):
|
||||
result = await service.review_pr(42)
|
||||
|
||||
assert "error" in result
|
||||
assert "not available" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_review_pr_get_repo_error(self, service):
|
||||
"""When _get_repo raises inside the method, returns error dict."""
|
||||
# The import of GitHubOrchestrator happens first in the try block,
|
||||
# so we need to provide a valid module, then let _get_repo fail.
|
||||
mock_orch_mod = MagicMock()
|
||||
|
||||
with patch.dict("sys.modules", {"runners.github.orchestrator": mock_orch_mod}):
|
||||
with patch.object(
|
||||
service, "_get_repo", side_effect=ValueError("no repo")
|
||||
):
|
||||
result = await service.review_pr(42)
|
||||
|
||||
assert "error" in result
|
||||
assert "no repo" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetReview:
|
||||
"""Tests for get_review() - loads saved review from disk."""
|
||||
|
||||
def test_get_review_found(self, service):
|
||||
"""When a review exists on disk, returns it."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.to_dict.return_value = {"verdict": "approve"}
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.models": MagicMock(
|
||||
PRReviewResult=MagicMock(
|
||||
load=MagicMock(return_value=mock_result)
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = service.get_review(42)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["verdict"] == "approve"
|
||||
|
||||
def test_get_review_not_found(self, service):
|
||||
"""When no review exists, returns error."""
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.models": MagicMock(
|
||||
PRReviewResult=MagicMock(
|
||||
load=MagicMock(return_value=None)
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = service.get_review(42)
|
||||
|
||||
assert "error" in result
|
||||
assert "No review found" in result["error"]
|
||||
|
||||
def test_get_review_import_error(self, service):
|
||||
"""When models module is unavailable, returns error."""
|
||||
with patch.dict("sys.modules", {"runners.github.models": None}):
|
||||
result = service.get_review(42)
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# auto_fix_issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoFixIssue:
|
||||
"""Tests for auto_fix_issue() - wraps GitHubOrchestrator.auto_fix_issue."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_auto_fix_success(self, service):
|
||||
"""Successful auto-fix returns serialized state."""
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_state = MagicMock()
|
||||
mock_state.to_dict.return_value = {"status": "completed"}
|
||||
mock_orchestrator.auto_fix_issue = AsyncMock(return_value=mock_state)
|
||||
|
||||
with patch.object(service, "_get_repo", return_value="owner/repo"):
|
||||
with patch.object(service, "_create_config") as mock_config:
|
||||
mock_config.return_value = MagicMock()
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.orchestrator": MagicMock(
|
||||
GitHubOrchestrator=MagicMock(
|
||||
return_value=mock_orchestrator
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.auto_fix_issue(10)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_auto_fix_exception(self, service):
|
||||
"""When auto_fix_issue raises, returns error."""
|
||||
with patch.object(
|
||||
service, "_get_repo", side_effect=ValueError("fail")
|
||||
):
|
||||
result = await service.auto_fix_issue(10)
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# triage_issues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTriageIssues:
|
||||
"""Tests for triage_issues() - wraps GitHubOrchestrator.triage_issues."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_triage_success(self, service):
|
||||
"""Successful triage returns list of serialized results."""
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_triage_result = MagicMock()
|
||||
mock_triage_result.to_dict.return_value = {"issue": 1, "category": "bug"}
|
||||
mock_orchestrator.triage_issues = AsyncMock(
|
||||
return_value=[mock_triage_result]
|
||||
)
|
||||
|
||||
with patch.object(service, "_get_repo", return_value="owner/repo"):
|
||||
with patch.object(service, "_create_config") as mock_config:
|
||||
mock_config.return_value = MagicMock()
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.orchestrator": MagicMock(
|
||||
GitHubOrchestrator=MagicMock(
|
||||
return_value=mock_orchestrator
|
||||
)
|
||||
)
|
||||
},
|
||||
):
|
||||
result = await service.triage_issues([1, 2])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
async def test_triage_exception(self, service):
|
||||
"""When triage raises, returns error dict."""
|
||||
with patch.object(
|
||||
service, "_get_repo", side_effect=ValueError("fail")
|
||||
):
|
||||
result = await service.triage_issues([1])
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _create_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateConfig:
|
||||
"""Tests for _create_config() - builds GitHubRunnerConfig."""
|
||||
|
||||
def test_creates_config_with_env_token(self, service):
|
||||
"""Uses GITHUB_TOKEN from environment if set."""
|
||||
mock_config_cls = MagicMock()
|
||||
mock_config_cls.return_value = MagicMock()
|
||||
|
||||
with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.models": MagicMock(
|
||||
GitHubRunnerConfig=mock_config_cls
|
||||
)
|
||||
},
|
||||
):
|
||||
config = service._create_config("owner/repo", "sonnet")
|
||||
|
||||
mock_config_cls.assert_called_once()
|
||||
call_kwargs = mock_config_cls.call_args[1]
|
||||
assert call_kwargs["token"] == "test-token"
|
||||
assert call_kwargs["repo"] == "owner/repo"
|
||||
assert call_kwargs["model"] == "sonnet"
|
||||
|
||||
def test_falls_back_to_gh_cli_token(self, service):
|
||||
"""When GITHUB_TOKEN is empty, tries gh auth token."""
|
||||
mock_config_cls = MagicMock()
|
||||
mock_config_cls.return_value = MagicMock()
|
||||
mock_gh_result = MagicMock(returncode=0, stdout="gh-cli-token\n")
|
||||
|
||||
with patch.dict("os.environ", {"GITHUB_TOKEN": ""}, clear=False):
|
||||
with patch("subprocess.run", return_value=mock_gh_result):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"runners.github.models": MagicMock(
|
||||
GitHubRunnerConfig=mock_config_cls
|
||||
)
|
||||
},
|
||||
):
|
||||
config = service._create_config("owner/repo")
|
||||
|
||||
call_kwargs = mock_config_cls.call_args[1]
|
||||
assert call_kwargs["token"] == "gh-cli-token"
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Tests for mcp_server/operations.py
|
||||
===================================
|
||||
|
||||
Tests the OperationTracker for creating, updating, cancelling,
|
||||
listing, and cleaning up long-running operations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from mcp_server.operations import Operation, OperationStatus, OperationTracker
|
||||
|
||||
|
||||
class TestOperationCreation:
|
||||
"""Tests for Operation dataclass and OperationTracker.create()."""
|
||||
|
||||
def test_create_returns_operation_with_unique_id(self):
|
||||
"""create() returns an Operation with a unique UUID."""
|
||||
tracker = OperationTracker()
|
||||
op1 = tracker.create("spec_create")
|
||||
op2 = tracker.create("build")
|
||||
assert op1.id != op2.id
|
||||
assert len(op1.id) == 36 # UUID format
|
||||
|
||||
def test_create_sets_pending_status(self):
|
||||
"""create() initializes operation with PENDING status."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("spec_create")
|
||||
assert op.status == OperationStatus.PENDING
|
||||
|
||||
def test_create_sets_operation_type(self):
|
||||
"""create() records the operation type."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("qa_review")
|
||||
assert op.type == "qa_review"
|
||||
|
||||
def test_create_uses_default_message(self):
|
||||
"""create() generates a default message from the operation type."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
assert op.message == "Starting build..."
|
||||
|
||||
def test_create_uses_custom_message(self):
|
||||
"""create() uses a custom message when provided."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("spec_create", message="Creating spec for feature X")
|
||||
assert op.message == "Creating spec for feature X"
|
||||
|
||||
def test_create_stores_operation(self):
|
||||
"""create() stores the operation so get() can find it."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
assert tracker.get(op.id) is op
|
||||
|
||||
def test_create_sets_timestamps(self):
|
||||
"""create() records created_at and updated_at timestamps."""
|
||||
tracker = OperationTracker()
|
||||
before = time.time()
|
||||
op = tracker.create("build")
|
||||
after = time.time()
|
||||
assert before <= op.created_at <= after
|
||||
assert before <= op.updated_at <= after
|
||||
|
||||
|
||||
class TestOperationGet:
|
||||
"""Tests for OperationTracker.get()."""
|
||||
|
||||
def test_get_returns_none_for_unknown_id(self):
|
||||
"""get() returns None for a nonexistent operation ID."""
|
||||
tracker = OperationTracker()
|
||||
assert tracker.get("nonexistent-id") is None
|
||||
|
||||
def test_get_returns_operation_by_id(self):
|
||||
"""get() returns the correct operation."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
result = tracker.get(op.id)
|
||||
assert result is op
|
||||
assert result.type == "build"
|
||||
|
||||
|
||||
class TestOperationUpdate:
|
||||
"""Tests for OperationTracker.update()."""
|
||||
|
||||
def test_update_modifies_status(self):
|
||||
"""update() changes the operation status."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, status=OperationStatus.RUNNING)
|
||||
assert op.status == OperationStatus.RUNNING
|
||||
|
||||
def test_update_modifies_progress(self):
|
||||
"""update() changes the progress value."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, progress=50)
|
||||
assert op.progress == 50
|
||||
|
||||
def test_update_clamps_progress_to_zero(self):
|
||||
"""update() clamps negative progress to 0."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, progress=-10)
|
||||
assert op.progress == 0
|
||||
|
||||
def test_update_clamps_progress_to_hundred(self):
|
||||
"""update() clamps progress above 100 to 100."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, progress=150)
|
||||
assert op.progress == 100
|
||||
|
||||
def test_update_modifies_message(self):
|
||||
"""update() changes the message."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, message="Building subtask 3/5...")
|
||||
assert op.message == "Building subtask 3/5..."
|
||||
|
||||
def test_update_sets_result(self):
|
||||
"""update() stores the result payload."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
result_data = {"files_changed": 5, "tests_passed": True}
|
||||
tracker.update(op.id, result=result_data)
|
||||
assert op.result == result_data
|
||||
|
||||
def test_update_sets_error(self):
|
||||
"""update() stores an error message."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, error="Build failed: syntax error in main.py")
|
||||
assert op.error == "Build failed: syntax error in main.py"
|
||||
|
||||
def test_update_updates_timestamp(self):
|
||||
"""update() refreshes the updated_at timestamp."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
old_time = op.updated_at
|
||||
time.sleep(0.01)
|
||||
tracker.update(op.id, progress=10)
|
||||
assert op.updated_at > old_time
|
||||
|
||||
def test_update_returns_operation(self):
|
||||
"""update() returns the updated operation."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
result = tracker.update(op.id, status=OperationStatus.RUNNING)
|
||||
assert result is op
|
||||
|
||||
def test_update_returns_none_for_unknown_id(self):
|
||||
"""update() returns None for a nonexistent operation."""
|
||||
tracker = OperationTracker()
|
||||
result = tracker.update("nonexistent", progress=50)
|
||||
assert result is None
|
||||
|
||||
def test_update_multiple_fields_at_once(self):
|
||||
"""update() can modify multiple fields in a single call."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=75,
|
||||
message="Almost done",
|
||||
)
|
||||
assert op.status == OperationStatus.RUNNING
|
||||
assert op.progress == 75
|
||||
assert op.message == "Almost done"
|
||||
|
||||
|
||||
class TestOperationCancel:
|
||||
"""Tests for OperationTracker.cancel()."""
|
||||
|
||||
def test_cancel_pending_operation(self):
|
||||
"""cancel() sets a PENDING operation to CANCELLED."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
result = tracker.cancel(op.id)
|
||||
assert result is True
|
||||
assert op.status == OperationStatus.CANCELLED
|
||||
assert op.message == "Operation cancelled by user"
|
||||
|
||||
def test_cancel_running_operation(self):
|
||||
"""cancel() sets a RUNNING operation to CANCELLED."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, status=OperationStatus.RUNNING)
|
||||
result = tracker.cancel(op.id)
|
||||
assert result is True
|
||||
assert op.status == OperationStatus.CANCELLED
|
||||
|
||||
def test_cancel_completed_operation_returns_false(self):
|
||||
"""cancel() returns False for COMPLETED operations."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, status=OperationStatus.COMPLETED)
|
||||
result = tracker.cancel(op.id)
|
||||
assert result is False
|
||||
assert op.status == OperationStatus.COMPLETED
|
||||
|
||||
def test_cancel_failed_operation_returns_false(self):
|
||||
"""cancel() returns False for FAILED operations."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(op.id, status=OperationStatus.FAILED)
|
||||
result = tracker.cancel(op.id)
|
||||
assert result is False
|
||||
assert op.status == OperationStatus.FAILED
|
||||
|
||||
def test_cancel_unknown_id_returns_false(self):
|
||||
"""cancel() returns False for a nonexistent operation."""
|
||||
tracker = OperationTracker()
|
||||
result = tracker.cancel("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_cancel_cancels_asyncio_task(self):
|
||||
"""cancel() calls task.cancel() on the associated asyncio task."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
mock_task = MagicMock(spec=asyncio.Task)
|
||||
mock_task.done.return_value = False
|
||||
op._task = mock_task
|
||||
tracker.cancel(op.id)
|
||||
mock_task.cancel.assert_called_once()
|
||||
|
||||
def test_cancel_does_not_cancel_done_task(self):
|
||||
"""cancel() skips task.cancel() when the asyncio task is already done."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
mock_task = MagicMock(spec=asyncio.Task)
|
||||
mock_task.done.return_value = True
|
||||
op._task = mock_task
|
||||
tracker.cancel(op.id)
|
||||
mock_task.cancel.assert_not_called()
|
||||
|
||||
def test_cancel_updates_timestamp(self):
|
||||
"""cancel() refreshes the updated_at timestamp."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
old_time = op.updated_at
|
||||
time.sleep(0.01)
|
||||
tracker.cancel(op.id)
|
||||
assert op.updated_at > old_time
|
||||
|
||||
|
||||
class TestListActive:
|
||||
"""Tests for OperationTracker.list_active()."""
|
||||
|
||||
def test_list_active_returns_pending_and_running(self):
|
||||
"""list_active() returns PENDING and RUNNING operations."""
|
||||
tracker = OperationTracker()
|
||||
op1 = tracker.create("build")
|
||||
op2 = tracker.create("qa_review")
|
||||
tracker.update(op2.id, status=OperationStatus.RUNNING)
|
||||
active = tracker.list_active()
|
||||
assert len(active) == 2
|
||||
assert op1 in active
|
||||
assert op2 in active
|
||||
|
||||
def test_list_active_excludes_terminal_states(self):
|
||||
"""list_active() excludes COMPLETED, FAILED, and CANCELLED."""
|
||||
tracker = OperationTracker()
|
||||
op1 = tracker.create("build")
|
||||
op2 = tracker.create("qa_review")
|
||||
op3 = tracker.create("spec_create")
|
||||
tracker.update(op1.id, status=OperationStatus.COMPLETED)
|
||||
tracker.update(op2.id, status=OperationStatus.FAILED)
|
||||
tracker.cancel(op3.id)
|
||||
|
||||
active = tracker.list_active()
|
||||
assert len(active) == 0
|
||||
|
||||
def test_list_active_empty_tracker(self):
|
||||
"""list_active() returns empty list when no operations exist."""
|
||||
tracker = OperationTracker()
|
||||
assert tracker.list_active() == []
|
||||
|
||||
|
||||
class TestCleanupOld:
|
||||
"""Tests for OperationTracker._cleanup_old()."""
|
||||
|
||||
def test_cleanup_removes_oldest_completed(self):
|
||||
"""_cleanup_old() removes oldest completed operations beyond max."""
|
||||
tracker = OperationTracker(max_completed=2)
|
||||
ops = []
|
||||
for i in range(4):
|
||||
op = tracker.create(f"build_{i}")
|
||||
op.created_at = i # deterministic ordering
|
||||
tracker.update(op.id, status=OperationStatus.COMPLETED)
|
||||
ops.append(op)
|
||||
|
||||
# Trigger cleanup by creating a new operation
|
||||
tracker.create("trigger")
|
||||
|
||||
# Oldest two should have been removed
|
||||
assert tracker.get(ops[0].id) is None
|
||||
assert tracker.get(ops[1].id) is None
|
||||
# Newest two should remain
|
||||
assert tracker.get(ops[2].id) is not None
|
||||
assert tracker.get(ops[3].id) is not None
|
||||
|
||||
def test_cleanup_does_not_remove_active_operations(self):
|
||||
"""_cleanup_old() only removes terminal-state operations."""
|
||||
tracker = OperationTracker(max_completed=1)
|
||||
active_op = tracker.create("build")
|
||||
tracker.update(active_op.id, status=OperationStatus.RUNNING)
|
||||
|
||||
completed_op = tracker.create("old_build")
|
||||
tracker.update(completed_op.id, status=OperationStatus.COMPLETED)
|
||||
|
||||
# Trigger cleanup
|
||||
tracker.create("trigger")
|
||||
|
||||
# Active operation should still exist
|
||||
assert tracker.get(active_op.id) is not None
|
||||
|
||||
def test_cleanup_under_limit_keeps_all(self):
|
||||
"""_cleanup_old() keeps all operations when under max_completed limit."""
|
||||
tracker = OperationTracker(max_completed=10)
|
||||
ops = []
|
||||
for i in range(3):
|
||||
op = tracker.create(f"build_{i}")
|
||||
tracker.update(op.id, status=OperationStatus.COMPLETED)
|
||||
ops.append(op)
|
||||
|
||||
# All should still exist
|
||||
for op in ops:
|
||||
assert tracker.get(op.id) is not None
|
||||
|
||||
|
||||
class TestOperationToDict:
|
||||
"""Tests for Operation.to_dict() serialization."""
|
||||
|
||||
def test_to_dict_contains_all_fields(self):
|
||||
"""to_dict() includes all expected fields."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("spec_create", message="Creating spec")
|
||||
result = op.to_dict()
|
||||
|
||||
expected_keys = {
|
||||
"id",
|
||||
"type",
|
||||
"status",
|
||||
"progress",
|
||||
"message",
|
||||
"result",
|
||||
"error",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"elapsed_seconds",
|
||||
}
|
||||
assert set(result.keys()) == expected_keys
|
||||
|
||||
def test_to_dict_serializes_status_as_string(self):
|
||||
"""to_dict() converts OperationStatus enum to its string value."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
result = op.to_dict()
|
||||
assert result["status"] == "pending"
|
||||
assert isinstance(result["status"], str)
|
||||
|
||||
def test_to_dict_computes_elapsed_seconds(self):
|
||||
"""to_dict() calculates elapsed time from creation."""
|
||||
op = Operation(id="test-id", type="build", created_at=time.time() - 5.0)
|
||||
result = op.to_dict()
|
||||
assert result["elapsed_seconds"] >= 4.9
|
||||
|
||||
def test_to_dict_serializes_result_and_error(self):
|
||||
"""to_dict() includes result and error fields."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error="Connection timeout",
|
||||
result={"partial": True},
|
||||
)
|
||||
result = op.to_dict()
|
||||
assert result["error"] == "Connection timeout"
|
||||
assert result["result"] == {"partial": True}
|
||||
assert result["status"] == "failed"
|
||||
|
||||
def test_to_dict_default_values(self):
|
||||
"""to_dict() includes correct defaults for a fresh operation."""
|
||||
tracker = OperationTracker()
|
||||
op = tracker.create("build")
|
||||
result = op.to_dict()
|
||||
assert result["progress"] == 0
|
||||
assert result["result"] is None
|
||||
assert result["error"] is None
|
||||
|
||||
|
||||
class TestOperationStatusEnum:
|
||||
"""Tests for OperationStatus enum values."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""OperationStatus has the expected string values."""
|
||||
assert OperationStatus.PENDING.value == "pending"
|
||||
assert OperationStatus.RUNNING.value == "running"
|
||||
assert OperationStatus.COMPLETED.value == "completed"
|
||||
assert OperationStatus.FAILED.value == "failed"
|
||||
assert OperationStatus.CANCELLED.value == "cancelled"
|
||||
|
||||
def test_status_is_string_enum(self):
|
||||
"""OperationStatus values are strings."""
|
||||
assert isinstance(OperationStatus.PENDING, str)
|
||||
assert OperationStatus.PENDING == "pending"
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Tests for mcp_server/tools/ops.py
|
||||
==================================
|
||||
|
||||
Tests operation status polling and cancellation tools.
|
||||
Uses the OperationTracker directly.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.operations import OperationStatus, OperationTracker, tracker
|
||||
from mcp_server.tools.ops import (
|
||||
operation_cancel as _operation_cancel_tool,
|
||||
operation_get_status as _operation_get_status_tool,
|
||||
)
|
||||
|
||||
# @mcp.tool() wraps functions as FunctionTool; access .fn for the raw callable
|
||||
operation_get_status = _operation_get_status_tool.fn
|
||||
operation_cancel = _operation_cancel_tool.fn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_tracker():
|
||||
"""Clear the global tracker between tests."""
|
||||
tracker._operations.clear()
|
||||
yield
|
||||
tracker._operations.clear()
|
||||
|
||||
|
||||
class TestOperationGetStatus:
|
||||
"""Tests for operation_get_status()."""
|
||||
|
||||
def test_returns_operation_dict(self):
|
||||
"""Returns the operation as a dict for a valid ID."""
|
||||
op = tracker.create("build", "Building spec: 001")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.RUNNING,
|
||||
progress=50,
|
||||
message="Halfway done",
|
||||
)
|
||||
|
||||
result = operation_get_status(op.id)
|
||||
|
||||
assert result["id"] == op.id
|
||||
assert result["type"] == "build"
|
||||
assert result["status"] == "running"
|
||||
assert result["progress"] == 50
|
||||
assert result["message"] == "Halfway done"
|
||||
|
||||
def test_returns_error_for_unknown_id(self):
|
||||
"""Returns error dict for a nonexistent operation ID."""
|
||||
result = operation_get_status("nonexistent-uuid")
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_returns_completed_operation(self):
|
||||
"""Returns completed operation with result."""
|
||||
op = tracker.create("qa_review", "QA for 001")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
message="QA approved",
|
||||
result={"status": "approved"},
|
||||
)
|
||||
|
||||
result = operation_get_status(op.id)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["progress"] == 100
|
||||
assert result["result"]["status"] == "approved"
|
||||
|
||||
def test_returns_failed_operation(self):
|
||||
"""Returns failed operation with error message."""
|
||||
op = tracker.create("build", "Building spec: 001")
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.FAILED,
|
||||
error="Process exited with code 1",
|
||||
)
|
||||
|
||||
result = operation_get_status(op.id)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "Process exited with code 1"
|
||||
|
||||
def test_includes_elapsed_time(self):
|
||||
"""Result includes elapsed_seconds field."""
|
||||
op = tracker.create("build", "test")
|
||||
result = operation_get_status(op.id)
|
||||
assert "elapsed_seconds" in result
|
||||
assert isinstance(result["elapsed_seconds"], float)
|
||||
|
||||
|
||||
class TestOperationCancel:
|
||||
"""Tests for operation_cancel()."""
|
||||
|
||||
def test_cancel_pending_operation(self):
|
||||
"""Cancels a pending operation successfully."""
|
||||
op = tracker.create("build", "Building spec: 001")
|
||||
result = operation_cancel(op.id)
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify it's actually cancelled
|
||||
status = operation_get_status(op.id)
|
||||
assert status["status"] == "cancelled"
|
||||
|
||||
def test_cancel_running_operation(self):
|
||||
"""Cancels a running operation."""
|
||||
op = tracker.create("build", "Building spec: 001")
|
||||
tracker.update(op.id, status=OperationStatus.RUNNING)
|
||||
|
||||
result = operation_cancel(op.id)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_cannot_cancel_completed_operation(self):
|
||||
"""Cannot cancel an already completed operation."""
|
||||
op = tracker.create("build", "Building spec: 001")
|
||||
tracker.update(op.id, status=OperationStatus.COMPLETED, progress=100)
|
||||
|
||||
result = operation_cancel(op.id)
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_cannot_cancel_failed_operation(self):
|
||||
"""Cannot cancel an already failed operation."""
|
||||
op = tracker.create("build", "Building spec: 001")
|
||||
tracker.update(op.id, status=OperationStatus.FAILED, error="failed")
|
||||
|
||||
result = operation_cancel(op.id)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_cancel_nonexistent_returns_error(self):
|
||||
"""Returns error when cancelling a nonexistent operation."""
|
||||
result = operation_cancel("nonexistent-uuid")
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_cancel_already_cancelled(self):
|
||||
"""Cancelling an already-cancelled operation still succeeds (idempotent within tracker)."""
|
||||
op = tracker.create("build", "test")
|
||||
# First cancel
|
||||
tracker.cancel(op.id)
|
||||
# Second cancel via the tool - should fail because it's in CANCELLED state
|
||||
# (CANCELLED is not COMPLETED/FAILED, but let's see if tracker.cancel handles it)
|
||||
result = operation_cancel(op.id)
|
||||
# The tracker.cancel checks for COMPLETED and FAILED states;
|
||||
# CANCELLED is neither, so it should succeed
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestOperationTrackerIntegration:
|
||||
"""Integration tests exercising the tracker lifecycle through tools."""
|
||||
|
||||
def test_create_poll_complete_lifecycle(self):
|
||||
"""Full lifecycle: create -> poll -> update -> complete -> poll."""
|
||||
op = tracker.create("spec_create", "Creating spec 001")
|
||||
|
||||
# Poll pending
|
||||
result = operation_get_status(op.id)
|
||||
assert result["status"] == "pending"
|
||||
|
||||
# Move to running
|
||||
tracker.update(op.id, status=OperationStatus.RUNNING, progress=30)
|
||||
result = operation_get_status(op.id)
|
||||
assert result["status"] == "running"
|
||||
assert result["progress"] == 30
|
||||
|
||||
# Complete
|
||||
tracker.update(
|
||||
op.id,
|
||||
status=OperationStatus.COMPLETED,
|
||||
progress=100,
|
||||
result={"spec_id": "001-feat"},
|
||||
)
|
||||
result = operation_get_status(op.id)
|
||||
assert result["status"] == "completed"
|
||||
assert result["result"]["spec_id"] == "001-feat"
|
||||
|
||||
def test_create_cancel_lifecycle(self):
|
||||
"""Full lifecycle: create -> poll -> cancel -> poll."""
|
||||
op = tracker.create("build", "Building")
|
||||
tracker.update(op.id, status=OperationStatus.RUNNING)
|
||||
|
||||
result = operation_cancel(op.id)
|
||||
assert result["success"] is True
|
||||
|
||||
status = operation_get_status(op.id)
|
||||
assert status["status"] == "cancelled"
|
||||
|
||||
def test_multiple_concurrent_operations(self):
|
||||
"""Multiple operations can coexist independently."""
|
||||
op1 = tracker.create("build", "Build 001")
|
||||
op2 = tracker.create("qa_review", "QA 002")
|
||||
|
||||
tracker.update(op1.id, status=OperationStatus.RUNNING, progress=50)
|
||||
tracker.update(op2.id, status=OperationStatus.COMPLETED, progress=100)
|
||||
|
||||
r1 = operation_get_status(op1.id)
|
||||
r2 = operation_get_status(op2.id)
|
||||
|
||||
assert r1["status"] == "running"
|
||||
assert r2["status"] == "completed"
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Tests for mcp_server/tools/project.py
|
||||
=======================================
|
||||
|
||||
Tests project management tools: set active, get status, list specs,
|
||||
and get project index. Uses monkeypatch to mock config module.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import mcp_server.config as config
|
||||
|
||||
# @mcp.tool() wraps functions as FunctionTool objects; access .fn for the raw callable
|
||||
from mcp_server.tools.project import (
|
||||
project_get_index as _project_get_index_tool,
|
||||
project_get_status as _project_get_status_tool,
|
||||
project_list_specs as _project_list_specs_tool,
|
||||
project_set_active as _project_set_active_tool,
|
||||
)
|
||||
|
||||
project_set_active = _project_set_active_tool.fn
|
||||
project_get_status = _project_get_status_tool.fn
|
||||
project_list_specs = _project_list_specs_tool.fn
|
||||
project_get_index = _project_get_index_tool.fn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_config():
|
||||
"""Reset config state between tests."""
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
yield
|
||||
config._project_dir = None
|
||||
config._auto_claude_dir = None
|
||||
|
||||
|
||||
class TestProjectSetActive:
|
||||
"""Tests for project_set_active()."""
|
||||
|
||||
def test_set_active_valid_project(self, tmp_path):
|
||||
"""Sets the active project directory."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
result = project_set_active(str(tmp_path))
|
||||
assert result["success"] is True
|
||||
assert result["initialized"] is True
|
||||
assert str(tmp_path.resolve()) in result["project_dir"]
|
||||
|
||||
def test_set_active_nonexistent_dir(self):
|
||||
"""Returns error for a nonexistent directory."""
|
||||
result = project_set_active("/nonexistent/path/to/project")
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_set_active_without_auto_claude(self, tmp_path):
|
||||
"""Sets project even without .auto-claude dir (warns but works)."""
|
||||
result = project_set_active(str(tmp_path))
|
||||
assert result["success"] is True
|
||||
# initialized should be False since .auto-claude doesn't exist on disk
|
||||
assert result["initialized"] is False
|
||||
|
||||
|
||||
class TestProjectGetStatus:
|
||||
"""Tests for project_get_status()."""
|
||||
|
||||
def test_returns_error_when_not_initialized(self):
|
||||
"""Returns error when no project has been set."""
|
||||
result = project_get_status()
|
||||
assert result["initialized"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_returns_status_with_specs(self, tmp_path):
|
||||
"""Returns project status including specs count."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "001-feature-a").mkdir()
|
||||
(specs_dir / "002-feature-b").mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_status()
|
||||
|
||||
assert result["initialized"] is True
|
||||
assert result["specs_count"] == 2
|
||||
assert str(tmp_path.resolve()) in result["project_dir"]
|
||||
|
||||
def test_excludes_gitkeep_from_count(self, tmp_path):
|
||||
"""Specs count excludes .gitkeep entries."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "001-feat").mkdir()
|
||||
(specs_dir / ".gitkeep").mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_status()
|
||||
|
||||
assert result["specs_count"] == 1
|
||||
|
||||
def test_returns_project_name_from_index(self, tmp_path):
|
||||
"""Uses project name from project_index.json when available."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
index = {"name": "My Awesome Project"}
|
||||
(ac_dir / "project_index.json").write_text(json.dumps(index))
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_status()
|
||||
|
||||
assert result["project_name"] == "My Awesome Project"
|
||||
|
||||
def test_uses_dir_name_as_fallback_project_name(self, tmp_path):
|
||||
"""Falls back to directory name when no project_index exists."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_status()
|
||||
|
||||
assert result["project_name"] == tmp_path.resolve().name
|
||||
|
||||
def test_handles_no_specs_dir(self, tmp_path):
|
||||
"""Returns zero specs when specs directory doesn't exist."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_status()
|
||||
|
||||
assert result["specs_count"] == 0
|
||||
|
||||
|
||||
class TestProjectListSpecs:
|
||||
"""Tests for project_list_specs()."""
|
||||
|
||||
def test_returns_error_when_not_initialized(self):
|
||||
"""Returns error when no project set."""
|
||||
result = project_list_specs()
|
||||
assert "error" in result
|
||||
|
||||
def test_lists_specs_with_plan(self, tmp_path):
|
||||
"""Returns spec list with plan details."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
spec_dir = specs_dir / "001-auth"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
plan = {"feature": "User Authentication", "status": "building"}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
(spec_dir / "spec.md").write_text("# Auth Spec")
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
assert result["count"] == 1
|
||||
spec = result["specs"][0]
|
||||
assert spec["name"] == "001-auth"
|
||||
assert spec["title"] == "User Authentication"
|
||||
assert spec["has_plan"] is True
|
||||
assert spec["has_spec"] is True
|
||||
assert spec["status"] == "building"
|
||||
|
||||
def test_lists_specs_without_plan(self, tmp_path):
|
||||
"""Returns spec with default status when no plan exists."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
spec_dir = specs_dir / "001-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
spec = result["specs"][0]
|
||||
assert spec["has_plan"] is False
|
||||
assert spec["status"] == "pending"
|
||||
assert spec["title"] == "001-feat"
|
||||
|
||||
def test_detects_qa_report(self, tmp_path):
|
||||
"""Returns has_qa_report=True when qa_report.md exists."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
spec_dir = specs_dir / "001-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "qa_report.md").write_text("# QA Report")
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
assert result["specs"][0]["has_qa_report"] is True
|
||||
|
||||
def test_empty_specs_dir(self, tmp_path):
|
||||
"""Returns empty list when specs directory is empty."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
(ac_dir / "specs").mkdir(parents=True)
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
assert result["count"] == 0
|
||||
assert result["specs"] == []
|
||||
|
||||
def test_no_specs_dir(self, tmp_path):
|
||||
"""Returns message when specs directory doesn't exist."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
assert result["specs"] == []
|
||||
assert "message" in result
|
||||
|
||||
def test_excludes_gitkeep(self, tmp_path):
|
||||
"""Excludes .gitkeep from specs list."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / ".gitkeep").mkdir()
|
||||
(specs_dir / "001-feat").mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["specs"][0]["name"] == "001-feat"
|
||||
|
||||
def test_handles_corrupt_plan(self, tmp_path):
|
||||
"""Handles corrupt implementation_plan.json gracefully."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
spec_dir = specs_dir / "001-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
(spec_dir / "implementation_plan.json").write_text("bad json!")
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
spec = result["specs"][0]
|
||||
assert spec["has_plan"] is True
|
||||
assert spec["status"] == "pending" # fallback
|
||||
|
||||
def test_specs_sorted_by_name(self, tmp_path):
|
||||
"""Specs are returned sorted by directory name."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
specs_dir = ac_dir / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "003-third").mkdir()
|
||||
(specs_dir / "001-first").mkdir()
|
||||
(specs_dir / "002-second").mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_list_specs()
|
||||
|
||||
names = [s["name"] for s in result["specs"]]
|
||||
assert names == ["001-first", "002-second", "003-third"]
|
||||
|
||||
|
||||
class TestProjectGetIndex:
|
||||
"""Tests for project_get_index()."""
|
||||
|
||||
def test_returns_error_when_not_initialized(self):
|
||||
"""Returns error when no project set."""
|
||||
result = project_get_index()
|
||||
assert "error" in result
|
||||
|
||||
def test_returns_index_data(self, tmp_path):
|
||||
"""Returns project index content."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
index_data = {"files": ["main.py"], "language": "python"}
|
||||
(ac_dir / "project_index.json").write_text(json.dumps(index_data))
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_index()
|
||||
|
||||
assert result["index"] == index_data
|
||||
|
||||
def test_returns_empty_when_no_index(self, tmp_path):
|
||||
"""Returns empty index when file doesn't exist."""
|
||||
ac_dir = tmp_path / ".auto-claude"
|
||||
ac_dir.mkdir()
|
||||
|
||||
config.initialize(tmp_path)
|
||||
result = project_get_index()
|
||||
|
||||
assert result["index"] == {}
|
||||
assert "message" in result
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Tests for mcp_server/services/qa_service.py
|
||||
=============================================
|
||||
|
||||
Tests QA report retrieval, manual approval, session number resolution,
|
||||
spec directory resolution, and start_review error paths.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.services.qa_service import QAService
|
||||
|
||||
|
||||
def _make_spec_dir(tmp_path: Path, spec_name: str = "001-feat") -> Path:
|
||||
"""Create a spec directory structure for testing."""
|
||||
specs_dir = tmp_path / ".auto-claude" / "specs" / spec_name
|
||||
specs_dir.mkdir(parents=True)
|
||||
return specs_dir
|
||||
|
||||
|
||||
class TestResolveSpecDir:
|
||||
"""Tests for QAService._resolve_spec_dir()."""
|
||||
|
||||
def test_exact_match(self, tmp_path):
|
||||
"""Resolves exact spec directory name."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feature")
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._resolve_spec_dir("001-feature") == spec_dir
|
||||
|
||||
def test_prefix_match(self, tmp_path):
|
||||
"""Resolves spec directory by prefix."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-my-feature")
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._resolve_spec_dir("001") == spec_dir
|
||||
|
||||
def test_no_match_returns_none(self, tmp_path):
|
||||
"""Returns None when no spec matches."""
|
||||
_make_spec_dir(tmp_path, "001-feat")
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._resolve_spec_dir("999") is None
|
||||
|
||||
def test_no_specs_dir_returns_none(self, tmp_path):
|
||||
"""Returns None when specs directory doesn't exist."""
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._resolve_spec_dir("001") is None
|
||||
|
||||
|
||||
class TestGetNextQaSession:
|
||||
"""Tests for QAService._get_next_qa_session()."""
|
||||
|
||||
def test_first_session_when_no_plan(self, tmp_path):
|
||||
"""Returns 1 when no implementation plan exists."""
|
||||
spec_dir = _make_spec_dir(tmp_path)
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._get_next_qa_session(spec_dir) == 1
|
||||
|
||||
def test_increments_from_existing_session(self, tmp_path):
|
||||
"""Increments the qa_session number from existing signoff."""
|
||||
spec_dir = _make_spec_dir(tmp_path)
|
||||
plan = {"qa_signoff": {"qa_session": 2, "status": "rejected"}}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._get_next_qa_session(spec_dir) == 3
|
||||
|
||||
def test_first_session_when_no_signoff(self, tmp_path):
|
||||
"""Returns 1 when plan exists but has no qa_signoff."""
|
||||
spec_dir = _make_spec_dir(tmp_path)
|
||||
plan = {"subtasks": []}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._get_next_qa_session(spec_dir) == 1
|
||||
|
||||
def test_handles_corrupt_plan(self, tmp_path):
|
||||
"""Returns 1 when plan file has invalid JSON."""
|
||||
spec_dir = _make_spec_dir(tmp_path)
|
||||
(spec_dir / "implementation_plan.json").write_text("not json!!")
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
assert svc._get_next_qa_session(spec_dir) == 1
|
||||
|
||||
|
||||
class TestStartReview:
|
||||
"""Tests for QAService.start_review()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_for_missing_spec(self, tmp_path):
|
||||
"""start_review returns error when spec not found."""
|
||||
(tmp_path / ".auto-claude" / "specs").mkdir(parents=True)
|
||||
svc = QAService(tmp_path)
|
||||
result = await svc.start_review("999-missing")
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_for_missing_plan(self, tmp_path):
|
||||
"""start_review returns error when no implementation plan exists."""
|
||||
_make_spec_dir(tmp_path, "001-feat")
|
||||
svc = QAService(tmp_path)
|
||||
result = await svc.start_review("001-feat")
|
||||
assert "error" in result
|
||||
assert "implementation plan" in result["error"].lower() or "Build" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_on_import_failure(self, tmp_path, monkeypatch):
|
||||
"""start_review returns error when backend QA modules unavailable."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
plan = {"subtasks": [{"status": "completed"}]}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
# The import of core.client / qa.reviewer will fail naturally
|
||||
# since we haven't set up the backend path properly for these modules
|
||||
result = await svc.start_review("001-feat")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestGetReport:
|
||||
"""Tests for QAService.get_report()."""
|
||||
|
||||
def test_returns_error_for_missing_spec(self, tmp_path):
|
||||
"""get_report returns error when spec not found."""
|
||||
(tmp_path / ".auto-claude" / "specs").mkdir(parents=True)
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("999-missing")
|
||||
assert "error" in result
|
||||
|
||||
def test_reads_qa_report(self, tmp_path):
|
||||
"""get_report returns qa_report.md content."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
report_text = "# QA Report\n\nAll tests passed."
|
||||
(spec_dir / "qa_report.md").write_text(report_text)
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("001-feat")
|
||||
assert result["report"] == report_text
|
||||
assert result["spec_id"] == "001-feat"
|
||||
|
||||
def test_reads_fix_request(self, tmp_path):
|
||||
"""get_report includes QA_FIX_REQUEST.md content when present."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
fix_text = "## Fix Required\n\nFix the error handling."
|
||||
(spec_dir / "QA_FIX_REQUEST.md").write_text(fix_text)
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("001-feat")
|
||||
assert result["fix_request"] == fix_text
|
||||
|
||||
def test_reads_qa_signoff(self, tmp_path):
|
||||
"""get_report includes qa_signoff from implementation plan."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
signoff = {"status": "approved", "qa_session": 2}
|
||||
plan = {"qa_signoff": signoff}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("001-feat")
|
||||
assert result["qa_signoff"] == signoff
|
||||
|
||||
def test_no_report_message(self, tmp_path):
|
||||
"""get_report returns 'no report' message when nothing exists."""
|
||||
_make_spec_dir(tmp_path, "001-feat")
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("001-feat")
|
||||
assert "No QA report" in result.get("message", "")
|
||||
|
||||
def test_reads_all_artifacts(self, tmp_path):
|
||||
"""get_report combines report, fix request, and signoff."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
(spec_dir / "qa_report.md").write_text("Report content")
|
||||
(spec_dir / "QA_FIX_REQUEST.md").write_text("Fix content")
|
||||
plan = {"qa_signoff": {"status": "rejected", "qa_session": 1}}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("001-feat")
|
||||
assert "report" in result
|
||||
assert "fix_request" in result
|
||||
assert "qa_signoff" in result
|
||||
|
||||
def test_handles_corrupt_plan_gracefully(self, tmp_path):
|
||||
"""get_report skips qa_signoff on corrupt plan file."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
(spec_dir / "qa_report.md").write_text("Report content")
|
||||
(spec_dir / "implementation_plan.json").write_text("bad json!")
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.get_report("001-feat")
|
||||
assert "report" in result
|
||||
assert "qa_signoff" not in result
|
||||
|
||||
|
||||
class TestApprove:
|
||||
"""Tests for QAService.approve()."""
|
||||
|
||||
def test_returns_error_for_missing_spec(self, tmp_path):
|
||||
"""approve returns error when spec not found."""
|
||||
(tmp_path / ".auto-claude" / "specs").mkdir(parents=True)
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.approve("999-missing")
|
||||
assert "error" in result
|
||||
|
||||
def test_returns_error_for_missing_plan(self, tmp_path):
|
||||
"""approve returns error when no implementation plan exists."""
|
||||
_make_spec_dir(tmp_path, "001-feat")
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.approve("001-feat")
|
||||
assert "error" in result
|
||||
assert "implementation plan" in result["error"].lower()
|
||||
|
||||
def test_writes_qa_signoff(self, tmp_path):
|
||||
"""approve writes qa_signoff to the implementation plan."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
plan = {"subtasks": [{"status": "completed"}]}
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.approve("001-feat")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["spec_id"] == "001-feat"
|
||||
|
||||
# Verify file was updated
|
||||
updated_plan = json.loads(plan_file.read_text())
|
||||
assert updated_plan["qa_signoff"]["status"] == "approved"
|
||||
assert updated_plan["qa_signoff"]["verified_by"] == "manual_approval"
|
||||
|
||||
def test_preserves_existing_session_number(self, tmp_path):
|
||||
"""approve preserves the existing qa_session number."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
plan = {"qa_signoff": {"qa_session": 3, "status": "rejected"}}
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan))
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
svc.approve("001-feat")
|
||||
|
||||
updated_plan = json.loads(plan_file.read_text())
|
||||
assert updated_plan["qa_signoff"]["qa_session"] == 3
|
||||
|
||||
def test_returns_error_on_corrupt_plan(self, tmp_path):
|
||||
"""approve returns error when plan file contains invalid JSON."""
|
||||
spec_dir = _make_spec_dir(tmp_path, "001-feat")
|
||||
(spec_dir / "implementation_plan.json").write_text("not json!")
|
||||
|
||||
svc = QAService(tmp_path)
|
||||
result = svc.approve("001-feat")
|
||||
assert "error" in result
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Tests for mcp_server/server.py
|
||||
===============================
|
||||
|
||||
Tests that the MCP server instance is configured correctly and
|
||||
all tools are registered.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
# Mock heavy backend dependencies that tool modules import
|
||||
_modules_to_mock = [
|
||||
"cli.utils",
|
||||
"core.client",
|
||||
"core.auth",
|
||||
"core.worktree",
|
||||
"agents",
|
||||
"agents.planner",
|
||||
"agents.coder",
|
||||
"agents.session",
|
||||
"qa",
|
||||
"qa.reviewer",
|
||||
"qa.fixer",
|
||||
"qa.loop",
|
||||
"qa.criteria",
|
||||
"spec",
|
||||
"spec.pipeline",
|
||||
"context",
|
||||
"context.builder",
|
||||
"context.search",
|
||||
"runners",
|
||||
"runners.spec_runner",
|
||||
"runners.roadmap",
|
||||
"runners.insights",
|
||||
"runners.github",
|
||||
"runners.github.services",
|
||||
"runners.github.services.parallel_orchestrator_reviewer",
|
||||
"services",
|
||||
"services.recovery",
|
||||
"integrations",
|
||||
"integrations.graphiti",
|
||||
"integrations.github",
|
||||
"project",
|
||||
"project.analysis",
|
||||
"merge",
|
||||
]
|
||||
for mod_name in _modules_to_mock:
|
||||
if mod_name not in sys.modules:
|
||||
sys.modules[mod_name] = MagicMock()
|
||||
|
||||
|
||||
class TestMCPServerInstance:
|
||||
"""Tests for the MCP server instance."""
|
||||
|
||||
def test_mcp_instance_exists(self):
|
||||
"""The mcp server instance is created."""
|
||||
from mcp_server.server import mcp
|
||||
|
||||
assert mcp is not None
|
||||
|
||||
def test_mcp_instance_name(self):
|
||||
"""The mcp server has the correct name."""
|
||||
from mcp_server.server import mcp
|
||||
|
||||
assert mcp.name == "Auto Claude"
|
||||
|
||||
|
||||
class TestRegisterAllTools:
|
||||
"""Tests for register_all_tools()."""
|
||||
|
||||
def test_register_all_tools_registers_43_tools(self):
|
||||
"""register_all_tools() registers exactly 43 tools."""
|
||||
from mcp_server.server import mcp, register_all_tools
|
||||
|
||||
register_all_tools()
|
||||
|
||||
# FastMCP stores tools in _tool_manager._tools dict
|
||||
tool_count = len(mcp._tool_manager._tools)
|
||||
assert tool_count == 43, (
|
||||
f"Expected 43 tools, got {tool_count}. "
|
||||
f"Tools: {sorted(mcp._tool_manager._tools.keys())}"
|
||||
)
|
||||
|
||||
def test_register_all_tools_idempotent(self):
|
||||
"""Calling register_all_tools() multiple times doesn't duplicate tools."""
|
||||
from mcp_server.server import mcp, register_all_tools
|
||||
|
||||
register_all_tools()
|
||||
count_first = len(mcp._tool_manager._tools)
|
||||
|
||||
register_all_tools()
|
||||
count_second = len(mcp._tool_manager._tools)
|
||||
|
||||
assert count_first == count_second
|
||||
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Tests for MCP Spec Service and Spec Tools
|
||||
==========================================
|
||||
|
||||
Tests SpecService (spec status, content, listing) and the 4 spec_* MCP tools.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.services.spec_service import SpecService
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_spec_dir(
|
||||
specs_dir: Path,
|
||||
name: str,
|
||||
*,
|
||||
plan: dict | None = None,
|
||||
requirements: dict | None = None,
|
||||
complexity: dict | None = None,
|
||||
spec_md: str | None = None,
|
||||
discovery_md: str | None = None,
|
||||
qa_report: str | None = None,
|
||||
) -> Path:
|
||||
"""Create a spec directory with optional files."""
|
||||
d = specs_dir / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
if plan is not None:
|
||||
(d / "implementation_plan.json").write_text(json.dumps(plan), encoding="utf-8")
|
||||
if requirements is not None:
|
||||
(d / "requirements.json").write_text(
|
||||
json.dumps(requirements), encoding="utf-8"
|
||||
)
|
||||
if complexity is not None:
|
||||
(d / "complexity_assessment.json").write_text(
|
||||
json.dumps(complexity), encoding="utf-8"
|
||||
)
|
||||
if spec_md is not None:
|
||||
(d / "spec.md").write_text(spec_md, encoding="utf-8")
|
||||
if discovery_md is not None:
|
||||
(d / "discovery.md").write_text(discovery_md, encoding="utf-8")
|
||||
if qa_report is not None:
|
||||
(d / "qa_report.md").write_text(qa_report, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SpecService - get_spec_status
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecServiceGetSpecStatus:
|
||||
def test_not_found(self, tmp_path):
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("999-nope")
|
||||
assert "error" in result
|
||||
|
||||
def test_pending_no_files(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "pending"
|
||||
assert result["phases"]["discovery"] is False
|
||||
assert result["phases"]["requirements"] is False
|
||||
assert result["phases"]["spec"] is False
|
||||
assert result["phases"]["implementation_plan"] is False
|
||||
|
||||
def test_discovery_complete(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", discovery_md="# Discovery\nFindings here")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "discovery_complete"
|
||||
assert result["phases"]["discovery"] is True
|
||||
|
||||
def test_requirements_gathered(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
discovery_md="# D",
|
||||
requirements={"task_description": "Do thing"},
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "requirements_gathered"
|
||||
assert result["phases"]["requirements"] is True
|
||||
|
||||
def test_spec_complete(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
discovery_md="# D",
|
||||
requirements={"task_description": "X"},
|
||||
spec_md="# Spec\nContent here",
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "spec_complete"
|
||||
assert result["phases"]["spec"] is True
|
||||
|
||||
def test_ready_to_build(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
discovery_md="# D",
|
||||
requirements={"task_description": "X"},
|
||||
spec_md="# Spec",
|
||||
plan={"feature": "F", "phases": []},
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "ready_to_build"
|
||||
assert result["phases"]["implementation_plan"] is True
|
||||
|
||||
def test_qa_reviewed(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
qa_report="# QA Report\nAll good",
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "qa_reviewed"
|
||||
|
||||
def test_qa_approved(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={
|
||||
"feature": "F",
|
||||
"qa_signoff": {"status": "approved"},
|
||||
},
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "qa_approved"
|
||||
|
||||
def test_qa_rejected(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={
|
||||
"feature": "F",
|
||||
"qa_signoff": {"status": "rejected"},
|
||||
},
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["status"] == "qa_rejected"
|
||||
|
||||
def test_complexity_assessment_phase(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
requirements={"task_description": "X"},
|
||||
complexity={"complexity": "standard"},
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert result["phases"]["complexity_assessment"] is True
|
||||
|
||||
def test_prefix_matching(self, tmp_path):
|
||||
"""get_spec_status supports prefix matching (e.g., '001' matches '001-feat')."""
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-my-feature", plan={"feature": "F"})
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001")
|
||||
assert result["spec_id"] == "001-my-feature"
|
||||
assert result["status"] == "ready_to_build"
|
||||
|
||||
def test_returns_spec_dir_path(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_status("001-feat")
|
||||
assert "spec_dir" in result
|
||||
assert result["spec_dir"].endswith("001-feat")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SpecService - get_spec_content
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecServiceGetSpecContent:
|
||||
def test_not_found(self, tmp_path):
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("999-nope")
|
||||
assert "error" in result
|
||||
|
||||
def test_reads_spec_md(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", spec_md="# My Spec\n\nContent here.")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-feat")
|
||||
assert "spec_md" in result
|
||||
assert "My Spec" in result["spec_md"]
|
||||
|
||||
def test_reads_requirements(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs, "001-feat", requirements={"task_description": "Build auth"}
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-feat")
|
||||
assert "requirements" in result
|
||||
assert result["requirements"]["task_description"] == "Build auth"
|
||||
|
||||
def test_reads_implementation_plan(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "Auth", "phases": [{"name": "Phase 1"}]},
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-feat")
|
||||
assert "implementation_plan" in result
|
||||
assert result["implementation_plan"]["feature"] == "Auth"
|
||||
|
||||
def test_reads_complexity_assessment(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs, "001-feat", complexity={"complexity": "standard", "confidence": 0.9}
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-feat")
|
||||
assert "complexity_assessment" in result
|
||||
assert result["complexity_assessment"]["complexity"] == "standard"
|
||||
|
||||
def test_reads_qa_report(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", qa_report="# QA Report\n\nAll tests pass.")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-feat")
|
||||
assert "qa_report" in result
|
||||
assert "All tests pass" in result["qa_report"]
|
||||
|
||||
def test_empty_spec_dir(self, tmp_path):
|
||||
"""A spec dir with no files returns only spec_id and spec_dir."""
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-empty")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-empty")
|
||||
assert result["spec_id"] == "001-empty"
|
||||
assert "spec_md" not in result
|
||||
assert "requirements" not in result
|
||||
assert "implementation_plan" not in result
|
||||
|
||||
def test_prefix_matching(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-my-feature", spec_md="# Content")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001")
|
||||
assert result["spec_id"] == "001-my-feature"
|
||||
assert "spec_md" in result
|
||||
|
||||
def test_all_files_present(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-full",
|
||||
plan={"feature": "Full"},
|
||||
requirements={"task_description": "Build it"},
|
||||
complexity={"complexity": "complex"},
|
||||
spec_md="# Full Spec",
|
||||
qa_report="# QA\nAll good",
|
||||
)
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.get_spec_content("001-full")
|
||||
assert "spec_md" in result
|
||||
assert "requirements" in result
|
||||
assert "implementation_plan" in result
|
||||
assert "complexity_assessment" in result
|
||||
assert "qa_report" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SpecService - list_specs
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecServiceListSpecs:
|
||||
def test_no_specs_dir(self, tmp_path):
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc.list_specs() == []
|
||||
|
||||
def test_empty_specs_dir(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc.list_specs() == []
|
||||
|
||||
def test_lists_multiple_specs(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-first", plan={"feature": "First"})
|
||||
_make_spec_dir(specs, "002-second", requirements={"task_description": "X"})
|
||||
_make_spec_dir(specs, "003-third")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.list_specs()
|
||||
assert len(result) == 3
|
||||
ids = [s["spec_id"] for s in result]
|
||||
assert "001-first" in ids
|
||||
assert "002-second" in ids
|
||||
assert "003-third" in ids
|
||||
|
||||
def test_skips_hidden_dirs(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-visible")
|
||||
(specs / ".hidden-dir").mkdir()
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.list_specs()
|
||||
assert len(result) == 1
|
||||
assert result[0]["spec_id"] == "001-visible"
|
||||
|
||||
def test_skips_files(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-visible")
|
||||
(specs / "some-file.txt").write_text("not a dir")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.list_specs()
|
||||
assert len(result) == 1
|
||||
|
||||
def test_sorted_order(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "003-c")
|
||||
_make_spec_dir(specs, "001-a")
|
||||
_make_spec_dir(specs, "002-b")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.list_specs()
|
||||
ids = [s["spec_id"] for s in result]
|
||||
assert ids == ["001-a", "002-b", "003-c"]
|
||||
|
||||
def test_each_entry_has_status(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F"})
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc.list_specs()
|
||||
assert "status" in result[0]
|
||||
assert "phases" in result[0]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SpecService - _resolve_spec_dir
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecServiceResolveSpecDir:
|
||||
def test_exact_match(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-exact-name")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc._resolve_spec_dir(specs, "001-exact-name")
|
||||
assert result is not None
|
||||
assert result.name == "001-exact-name"
|
||||
|
||||
def test_prefix_match(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-my-feature")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc._resolve_spec_dir(specs, "001")
|
||||
assert result is not None
|
||||
assert result.name == "001-my-feature"
|
||||
|
||||
def test_no_match(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc._resolve_spec_dir(specs, "999") is None
|
||||
|
||||
def test_prefers_exact_over_prefix(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001")
|
||||
_make_spec_dir(specs, "001-with-slug")
|
||||
svc = SpecService(tmp_path)
|
||||
result = svc._resolve_spec_dir(specs, "001")
|
||||
assert result.name == "001"
|
||||
|
||||
def test_no_specs_dir(self, tmp_path):
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc._resolve_spec_dir(tmp_path / "nonexistent", "001") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SpecService - _load_json
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecServiceLoadJson:
|
||||
def test_valid_json(self, tmp_path):
|
||||
p = tmp_path / "data.json"
|
||||
p.write_text('{"key": "value"}')
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc._load_json(p) == {"key": "value"}
|
||||
|
||||
def test_invalid_json(self, tmp_path):
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("not json")
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc._load_json(p) is None
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
svc = SpecService(tmp_path)
|
||||
assert svc._load_json(tmp_path / "nope.json") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SpecService - create_spec (async, mocked)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecServiceCreateSpec:
|
||||
async def test_import_error(self, tmp_path, monkeypatch):
|
||||
"""When SpecOrchestrator can't be imported, returns error."""
|
||||
svc = SpecService(tmp_path)
|
||||
# Ensure the import inside create_spec fails
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if "orchestrator" in name:
|
||||
raise ImportError("no module")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", mock_import)
|
||||
result = await svc.create_spec("build a thing")
|
||||
assert result["success"] is False
|
||||
assert "not available" in result["error"]
|
||||
|
||||
async def test_orchestrator_exception(self, tmp_path, monkeypatch):
|
||||
"""When orchestrator raises, returns error."""
|
||||
svc = SpecService(tmp_path)
|
||||
|
||||
mock_orch_cls = MagicMock()
|
||||
mock_orch_cls.side_effect = RuntimeError("boom")
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"spec.pipeline.orchestrator",
|
||||
MagicMock(SpecOrchestrator=mock_orch_cls),
|
||||
)
|
||||
result = await svc.create_spec("task")
|
||||
assert result["success"] is False
|
||||
assert "boom" in result["error"]
|
||||
|
||||
async def test_successful_spec_creation(self, tmp_path, monkeypatch):
|
||||
"""When orchestrator succeeds, returns success with spec info."""
|
||||
svc = SpecService(tmp_path)
|
||||
|
||||
spec_dir = tmp_path / ".auto-claude" / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.spec_dir = spec_dir
|
||||
mock_instance.run = MagicMock(return_value=True)
|
||||
# Make run a coroutine
|
||||
import asyncio
|
||||
|
||||
async def mock_run(**kwargs):
|
||||
return True
|
||||
|
||||
mock_instance.run = mock_run
|
||||
|
||||
mock_orch_cls = MagicMock(return_value=mock_instance)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"spec.pipeline.orchestrator",
|
||||
MagicMock(SpecOrchestrator=mock_orch_cls),
|
||||
)
|
||||
result = await svc.create_spec("build a thing")
|
||||
assert result["success"] is True
|
||||
assert result["spec_id"] == "001-test"
|
||||
assert "spec_dir" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Spec Tools (mcp_server.tools.specs)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpecTools:
|
||||
"""Tests for the MCP spec_* tool functions.
|
||||
|
||||
FastMCP's @mcp.tool() wraps functions in FunctionTool objects.
|
||||
We call the underlying function via .fn to test the tool logic directly.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_config(self, tmp_path, monkeypatch):
|
||||
"""Mock get_project_dir for all tool tests."""
|
||||
import mcp_server.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "_project_dir", tmp_path)
|
||||
monkeypatch.setattr(config_mod, "_auto_claude_dir", tmp_path / ".auto-claude")
|
||||
self.project_dir = tmp_path
|
||||
self.specs_dir = tmp_path / ".auto-claude" / "specs"
|
||||
|
||||
def test_spec_get_status_success(self):
|
||||
from mcp_server.tools.specs import spec_get_status
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
spec_md="# Spec",
|
||||
)
|
||||
result = spec_get_status.fn("001-feat")
|
||||
assert result["status"] == "ready_to_build"
|
||||
assert result["spec_id"] == "001-feat"
|
||||
|
||||
def test_spec_get_status_not_found(self):
|
||||
from mcp_server.tools.specs import spec_get_status
|
||||
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = spec_get_status.fn("999-nope")
|
||||
assert "error" in result
|
||||
|
||||
def test_spec_get_status_prefix(self):
|
||||
from mcp_server.tools.specs import spec_get_status
|
||||
|
||||
_make_spec_dir(self.specs_dir, "001-my-feature", plan={"feature": "F"})
|
||||
result = spec_get_status.fn("001")
|
||||
assert result["spec_id"] == "001-my-feature"
|
||||
|
||||
def test_spec_get_content_success(self):
|
||||
from mcp_server.tools.specs import spec_get_content
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
spec_md="# The Spec",
|
||||
requirements={"task_description": "Build it"},
|
||||
plan={"feature": "Feature"},
|
||||
)
|
||||
result = spec_get_content.fn("001-feat")
|
||||
assert "spec_md" in result
|
||||
assert "requirements" in result
|
||||
assert "implementation_plan" in result
|
||||
|
||||
def test_spec_get_content_not_found(self):
|
||||
from mcp_server.tools.specs import spec_get_content
|
||||
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = spec_get_content.fn("999-nope")
|
||||
assert "error" in result
|
||||
|
||||
def test_spec_list_success(self):
|
||||
from mcp_server.tools.specs import spec_list
|
||||
|
||||
_make_spec_dir(self.specs_dir, "001-first", plan={"feature": "First"})
|
||||
_make_spec_dir(self.specs_dir, "002-second")
|
||||
result = spec_list.fn()
|
||||
assert result["count"] == 2
|
||||
assert len(result["specs"]) == 2
|
||||
|
||||
def test_spec_list_empty(self):
|
||||
from mcp_server.tools.specs import spec_list
|
||||
|
||||
result = spec_list.fn()
|
||||
assert result["count"] == 0
|
||||
assert result["specs"] == []
|
||||
|
||||
def test_spec_create_is_async(self):
|
||||
"""spec_create wraps an async function for long-running operations."""
|
||||
from mcp_server.tools.specs import spec_create
|
||||
import inspect
|
||||
|
||||
# The underlying fn is async (wrapped by FastMCP)
|
||||
assert inspect.iscoroutinefunction(spec_create.fn)
|
||||
@@ -0,0 +1,937 @@
|
||||
"""
|
||||
Tests for MCP Task Service and Task Tools
|
||||
==========================================
|
||||
|
||||
Tests TaskService (CRUD on spec directories) and the 6 task_* MCP tools.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.services.task_service import (
|
||||
VALID_STATUSES,
|
||||
TaskService,
|
||||
_extract_spec_heading,
|
||||
_extract_spec_overview,
|
||||
_safe_read_json,
|
||||
_slugify,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_spec_dir(
|
||||
specs_dir: Path,
|
||||
name: str,
|
||||
*,
|
||||
plan: dict | None = None,
|
||||
requirements: dict | None = None,
|
||||
metadata: dict | None = None,
|
||||
spec_md: str | None = None,
|
||||
qa_report: str | None = None,
|
||||
) -> Path:
|
||||
"""Create a spec directory with optional files."""
|
||||
d = specs_dir / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
if plan is not None:
|
||||
(d / "implementation_plan.json").write_text(json.dumps(plan), encoding="utf-8")
|
||||
if requirements is not None:
|
||||
(d / "requirements.json").write_text(
|
||||
json.dumps(requirements), encoding="utf-8"
|
||||
)
|
||||
if metadata is not None:
|
||||
(d / "task_metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
|
||||
if spec_md is not None:
|
||||
(d / "spec.md").write_text(spec_md, encoding="utf-8")
|
||||
if qa_report is not None:
|
||||
(d / "qa_report.md").write_text(qa_report, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _slugify
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic_title(self):
|
||||
assert _slugify("My Feature") == "my-feature"
|
||||
|
||||
def test_special_characters(self):
|
||||
assert _slugify("Hello, World! (v2)") == "hello-world-v2"
|
||||
|
||||
def test_leading_trailing_whitespace(self):
|
||||
assert _slugify(" padded ") == "padded"
|
||||
|
||||
def test_multiple_spaces_and_dashes(self):
|
||||
assert _slugify("a b---c") == "a-b-c"
|
||||
|
||||
def test_truncates_to_80(self):
|
||||
result = _slugify("a" * 100)
|
||||
assert len(result) <= 80
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _slugify("") == ""
|
||||
|
||||
def test_unicode_preserved(self):
|
||||
# Python \w matches unicode word chars, so accented letters stay
|
||||
assert _slugify("café crème") == "café-crème"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _safe_read_json
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSafeReadJson:
|
||||
def test_valid_json(self, tmp_path):
|
||||
p = tmp_path / "data.json"
|
||||
p.write_text('{"key": 1}')
|
||||
assert _safe_read_json(p) == {"key": 1}
|
||||
|
||||
def test_invalid_json(self, tmp_path):
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("not json")
|
||||
assert _safe_read_json(p) is None
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
assert _safe_read_json(tmp_path / "nope.json") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _extract_spec_heading
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestExtractSpecHeading:
|
||||
def test_standard_heading(self, tmp_path):
|
||||
p = tmp_path / "spec.md"
|
||||
p.write_text("# My Cool Feature\n\nSome text")
|
||||
assert _extract_spec_heading(p) == "My Cool Feature"
|
||||
|
||||
def test_quick_spec_prefix(self, tmp_path):
|
||||
p = tmp_path / "spec.md"
|
||||
p.write_text("# Quick Spec: Login Page\n")
|
||||
assert _extract_spec_heading(p) == "Login Page"
|
||||
|
||||
def test_specification_prefix(self, tmp_path):
|
||||
p = tmp_path / "spec.md"
|
||||
p.write_text("# Specification: Auth Module\n")
|
||||
assert _extract_spec_heading(p) == "Auth Module"
|
||||
|
||||
def test_no_heading(self, tmp_path):
|
||||
p = tmp_path / "spec.md"
|
||||
p.write_text("No heading here\n")
|
||||
assert _extract_spec_heading(p) is None
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
assert _extract_spec_heading(tmp_path / "nope.md") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _extract_spec_overview
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestExtractSpecOverview:
|
||||
def test_overview_section(self, tmp_path):
|
||||
p = tmp_path / "spec.md"
|
||||
p.write_text("# Title\n\n## Overview\n\nThis is the overview.\n\n## Details\n")
|
||||
assert _extract_spec_overview(p) == "This is the overview."
|
||||
|
||||
def test_no_overview(self, tmp_path):
|
||||
p = tmp_path / "spec.md"
|
||||
p.write_text("# Title\n\n## Details\nStuff\n")
|
||||
assert _extract_spec_overview(p) is None
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
assert _extract_spec_overview(tmp_path / "nope.md") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - list_tasks
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceListTasks:
|
||||
def test_empty_specs_dir(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks() == []
|
||||
|
||||
def test_no_specs_dir(self, tmp_path):
|
||||
svc = TaskService(tmp_path / "nonexistent")
|
||||
assert svc.list_tasks() == []
|
||||
|
||||
def test_loads_tasks_from_specs(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-hello",
|
||||
plan={"feature": "Hello Feature", "status": "pending"},
|
||||
)
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"002-world",
|
||||
plan={"feature": "World Feature", "status": "in_progress"},
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
assert len(tasks) == 2
|
||||
ids = {t["spec_id"] for t in tasks}
|
||||
assert ids == {"001-hello", "002-world"}
|
||||
|
||||
def test_title_from_plan_feature(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "My Feature Title"})
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
assert tasks[0]["title"] == "My Feature Title"
|
||||
|
||||
def test_title_from_plan_title_field(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"title": "Title Field"})
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
assert tasks[0]["title"] == "Title Field"
|
||||
|
||||
def test_title_fallback_to_spec_heading(self, tmp_path):
|
||||
"""When plan title looks like a spec ID, fall back to spec.md heading."""
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "001-feat"},
|
||||
spec_md="# The Real Title\n\nContent here",
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
assert tasks[0]["title"] == "The Real Title"
|
||||
|
||||
def test_title_fallback_to_dir_name(self, tmp_path):
|
||||
"""When no plan exists and no spec.md heading, use dir name."""
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-some-slug")
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
# Dir name is the fallback, but it matches ^\d{3}- so spec heading is tried
|
||||
# Since no spec.md, stays as dir name
|
||||
assert tasks[0]["title"] == "001-some-slug"
|
||||
|
||||
def test_description_from_plan(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs, "001-feat", plan={"feature": "F", "description": "Plan desc"}
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks()[0]["description"] == "Plan desc"
|
||||
|
||||
def test_description_fallback_to_requirements(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
requirements={"task_description": "Req desc"},
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks()[0]["description"] == "Req desc"
|
||||
|
||||
def test_description_fallback_to_overview(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
spec_md="# Title\n\n## Overview\n\nOverview text.\n\n## Other\n",
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks()[0]["description"] == "Overview text."
|
||||
|
||||
def test_status_mapping(self, tmp_path):
|
||||
"""Frontend-style statuses are mapped to valid backend statuses."""
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-a", plan={"feature": "A", "status": "coding"})
|
||||
_make_spec_dir(specs, "002-b", plan={"feature": "B", "status": "completed"})
|
||||
_make_spec_dir(specs, "003-c", plan={"feature": "C", "status": "backlog"})
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = {t["spec_id"]: t for t in svc.list_tasks()}
|
||||
assert tasks["001-a"]["status"] == "in_progress"
|
||||
assert tasks["002-b"]["status"] == "done"
|
||||
assert tasks["003-c"]["status"] == "pending"
|
||||
|
||||
def test_unknown_status_defaults_to_pending(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs, "001-x", plan={"feature": "X", "status": "totally_unknown"}
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks()[0]["status"] == "pending"
|
||||
|
||||
def test_subtasks_from_phases(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
plan = {
|
||||
"feature": "F",
|
||||
"phases": [
|
||||
{
|
||||
"subtasks": [
|
||||
{"id": "s1", "description": "Do thing 1", "status": "pending"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"chunks": [
|
||||
{
|
||||
"id": "c1",
|
||||
"description": "Do chunk 1",
|
||||
"status": "completed",
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
_make_spec_dir(specs, "001-feat", plan=plan)
|
||||
svc = TaskService(tmp_path)
|
||||
task = svc.list_tasks()[0]
|
||||
assert len(task["subtasks"]) == 2
|
||||
assert task["subtasks"][0]["id"] == "s1"
|
||||
assert task["subtasks"][1]["id"] == "c1"
|
||||
|
||||
def test_has_spec_and_plan_flags(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
spec_md="# Spec",
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
task = svc.list_tasks()[0]
|
||||
assert task["has_spec"] is True
|
||||
assert task["has_plan"] is True
|
||||
|
||||
def test_has_qa_report_flag(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
qa_report="# QA passed",
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks()[0]["has_qa_report"] is True
|
||||
|
||||
def test_skips_gitkeep(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
(specs / ".gitkeep").mkdir() # Directory named .gitkeep
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.list_tasks() == []
|
||||
|
||||
def test_deduplication_main_wins_over_worktree(self, tmp_path):
|
||||
"""Main project task takes priority over worktree duplicate."""
|
||||
# Main specs
|
||||
main_specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
main_specs,
|
||||
"001-feat",
|
||||
plan={"feature": "Main Version", "status": "pending"},
|
||||
)
|
||||
|
||||
# Worktree with same spec id
|
||||
wt_dir = tmp_path / ".auto-claude" / "worktrees" / "tasks" / "wt1"
|
||||
wt_specs = wt_dir / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
wt_specs,
|
||||
"001-feat",
|
||||
plan={"feature": "Worktree Version", "status": "in_progress"},
|
||||
)
|
||||
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["title"] == "Main Version"
|
||||
assert tasks[0]["location"] == "main"
|
||||
|
||||
def test_worktree_only_included_if_exists_in_main(self, tmp_path):
|
||||
"""Worktree tasks are only included when their spec_id exists in main."""
|
||||
main_specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
main_specs,
|
||||
"001-feat",
|
||||
plan={"feature": "Main", "status": "pending"},
|
||||
)
|
||||
|
||||
wt_dir = tmp_path / ".auto-claude" / "worktrees" / "tasks" / "wt1"
|
||||
wt_specs = wt_dir / ".auto-claude" / "specs"
|
||||
# This spec doesn't exist in main
|
||||
_make_spec_dir(
|
||||
wt_specs,
|
||||
"099-orphan",
|
||||
plan={"feature": "Orphan", "status": "done"},
|
||||
)
|
||||
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["spec_id"] == "001-feat"
|
||||
|
||||
def test_dedup_same_location_higher_status_wins(self, tmp_path):
|
||||
"""When both are from same location, higher status priority wins."""
|
||||
# We can't easily have two entries from the same location for the same
|
||||
# spec_id via the normal scan since directories are unique. But we can
|
||||
# verify the dedup logic via worktree entries that pass the main_spec_ids filter.
|
||||
main_specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
main_specs,
|
||||
"001-feat",
|
||||
plan={"feature": "Feat", "status": "pending"},
|
||||
)
|
||||
|
||||
# Two worktrees with same spec
|
||||
for i, status in enumerate(["in_progress", "done"], 1):
|
||||
wt = tmp_path / ".auto-claude" / "worktrees" / "tasks" / f"wt{i}"
|
||||
wt_specs = wt / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
wt_specs,
|
||||
"001-feat",
|
||||
plan={"feature": "Feat", "status": status},
|
||||
)
|
||||
|
||||
svc = TaskService(tmp_path)
|
||||
tasks = svc.list_tasks()
|
||||
# Main should win regardless
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["location"] == "main"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - get_task
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceGetTask:
|
||||
def test_existing_task(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F", "status": "pending"})
|
||||
svc = TaskService(tmp_path)
|
||||
task = svc.get_task("001-feat")
|
||||
assert task is not None
|
||||
assert task["spec_id"] == "001-feat"
|
||||
assert task["title"] == "F"
|
||||
|
||||
def test_missing_task(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.get_task("999-nope") is None
|
||||
|
||||
def test_get_task_from_worktree(self, tmp_path):
|
||||
# No main spec, but exists in worktree
|
||||
(tmp_path / ".auto-claude" / "specs").mkdir(parents=True)
|
||||
wt = tmp_path / ".auto-claude" / "worktrees" / "tasks" / "wt1"
|
||||
wt_specs = wt / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
wt_specs,
|
||||
"001-feat",
|
||||
plan={"feature": "From WT", "status": "in_progress"},
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
task = svc.get_task("001-feat")
|
||||
assert task is not None
|
||||
assert task["title"] == "From WT"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - create_task
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceCreateTask:
|
||||
def test_creates_directory_and_files(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("My New Task", "Build something cool")
|
||||
assert result["title"] == "My New Task"
|
||||
assert result["status"] == "pending"
|
||||
|
||||
spec_dir = tmp_path / ".auto-claude" / "specs" / result["spec_id"]
|
||||
assert spec_dir.is_dir()
|
||||
assert (spec_dir / "implementation_plan.json").exists()
|
||||
assert (spec_dir / "requirements.json").exists()
|
||||
assert (spec_dir / "task_metadata.json").exists()
|
||||
|
||||
def test_auto_numbering(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-existing", plan={"feature": "Existing"})
|
||||
_make_spec_dir(specs, "005-jump", plan={"feature": "Jump"})
|
||||
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("Next Task", "Desc")
|
||||
# Should be 006 (one after max existing = 005)
|
||||
assert result["spec_id"].startswith("006-")
|
||||
|
||||
def test_auto_numbering_empty(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("First Task", "Desc")
|
||||
assert result["spec_id"].startswith("001-")
|
||||
|
||||
def test_slug_in_dir_name(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("Add OAuth Support!", "Desc")
|
||||
assert "add-oauth-support" in result["spec_id"]
|
||||
|
||||
def test_plan_contents(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("Title", "Description text")
|
||||
spec_dir = tmp_path / ".auto-claude" / "specs" / result["spec_id"]
|
||||
plan = json.loads((spec_dir / "implementation_plan.json").read_text())
|
||||
assert plan["feature"] == "Title"
|
||||
assert plan["title"] == "Title"
|
||||
assert plan["description"] == "Description text"
|
||||
assert plan["status"] == "pending"
|
||||
assert "created_at" in plan
|
||||
assert "updated_at" in plan
|
||||
|
||||
def test_requirements_contents(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("Title", "My desc")
|
||||
spec_dir = tmp_path / ".auto-claude" / "specs" / result["spec_id"]
|
||||
reqs = json.loads((spec_dir / "requirements.json").read_text())
|
||||
assert reqs["task_description"] == "My desc"
|
||||
|
||||
def test_metadata_contents(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.create_task("Title", "Desc")
|
||||
spec_dir = tmp_path / ".auto-claude" / "specs" / result["spec_id"]
|
||||
meta = json.loads((spec_dir / "task_metadata.json").read_text())
|
||||
assert meta["source"] == "mcp"
|
||||
assert "created_at" in meta
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - update_task
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceUpdateTask:
|
||||
def test_update_title(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "Old", "status": "pending"})
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.update_task("001-feat", title="New Title")
|
||||
assert result is not None
|
||||
assert result["title"] == "New Title"
|
||||
# Verify on disk
|
||||
plan = json.loads(
|
||||
(specs / "001-feat" / "implementation_plan.json").read_text()
|
||||
)
|
||||
assert plan["feature"] == "New Title"
|
||||
assert plan["title"] == "New Title"
|
||||
|
||||
def test_update_description(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={"feature": "F", "status": "pending"},
|
||||
requirements={"task_description": "Old desc"},
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.update_task("001-feat", description="New desc")
|
||||
assert result is not None
|
||||
# Verify requirements.json updated too
|
||||
reqs = json.loads((specs / "001-feat" / "requirements.json").read_text())
|
||||
assert reqs["task_description"] == "New desc"
|
||||
|
||||
def test_update_status(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F", "status": "pending"})
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.update_task("001-feat", status="in_progress")
|
||||
assert result is not None
|
||||
assert result["status"] == "in_progress"
|
||||
|
||||
def test_update_invalid_status(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F", "status": "pending"})
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.update_task("001-feat", status="bogus")
|
||||
assert result is None
|
||||
|
||||
def test_update_nonexistent(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.update_task("999-nope", title="X") is None
|
||||
|
||||
def test_updated_at_changes(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(
|
||||
specs,
|
||||
"001-feat",
|
||||
plan={
|
||||
"feature": "F",
|
||||
"status": "pending",
|
||||
"updated_at": "2020-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
svc.update_task("001-feat", title="Changed")
|
||||
plan = json.loads(
|
||||
(specs / "001-feat" / "implementation_plan.json").read_text()
|
||||
)
|
||||
assert plan["updated_at"] != "2020-01-01T00:00:00"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - delete_task
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceDeleteTask:
|
||||
def test_delete_existing(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F"})
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.delete_task("001-feat") is True
|
||||
assert not (specs / "001-feat").exists()
|
||||
|
||||
def test_delete_nonexistent(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.delete_task("999-nope") is False
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
specs.mkdir(parents=True)
|
||||
# Create a dir outside specs that a traversal would target
|
||||
outside = tmp_path / "important"
|
||||
outside.mkdir()
|
||||
(outside / "data.txt").write_text("precious")
|
||||
|
||||
svc = TaskService(tmp_path)
|
||||
# Even if the traversal path resolves to an existing directory,
|
||||
# the relative_to check should block it
|
||||
result = svc.delete_task("../../important")
|
||||
# The directory should not be deleted
|
||||
assert outside.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - update_status
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceUpdateStatus:
|
||||
def test_valid_status(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F", "status": "pending"})
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.update_status("001-feat", "done")
|
||||
assert result is not None
|
||||
assert result["status"] == "done"
|
||||
|
||||
def test_invalid_status(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "001-feat", plan={"feature": "F", "status": "pending"})
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc.update_status("001-feat", "invalid") is None
|
||||
|
||||
def test_all_valid_statuses_accepted(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
for i, status in enumerate(sorted(VALID_STATUSES), 1):
|
||||
name = f"{i:03d}-test"
|
||||
_make_spec_dir(
|
||||
specs, name, plan={"feature": f"Task {i}", "status": "pending"}
|
||||
)
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc.update_status(name, status)
|
||||
assert result is not None, f"Status '{status}' should be valid"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskService - internal helpers
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskServiceInternalHelpers:
|
||||
def test_next_spec_number_empty(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc._next_spec_number() == 1
|
||||
|
||||
def test_next_spec_number_existing(self, tmp_path):
|
||||
specs = tmp_path / ".auto-claude" / "specs"
|
||||
_make_spec_dir(specs, "003-thing")
|
||||
_make_spec_dir(specs, "010-other")
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc._next_spec_number() == 11
|
||||
|
||||
def test_find_spec_dir_in_worktrees(self, tmp_path):
|
||||
wt = tmp_path / ".auto-claude" / "worktrees" / "tasks" / "wt1"
|
||||
wt_specs = wt / ".auto-claude" / "specs"
|
||||
_make_spec_dir(wt_specs, "001-feat", plan={"feature": "F"})
|
||||
svc = TaskService(tmp_path)
|
||||
result = svc._find_spec_dir_in_worktrees("001-feat")
|
||||
assert result is not None
|
||||
assert result.name == "001-feat"
|
||||
|
||||
def test_find_spec_dir_in_worktrees_not_found(self, tmp_path):
|
||||
svc = TaskService(tmp_path)
|
||||
assert svc._find_spec_dir_in_worktrees("999-nope") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task Tools (mcp_server.tools.tasks)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTaskTools:
|
||||
"""Tests for the MCP task_* tool functions.
|
||||
|
||||
FastMCP's @mcp.tool() wraps functions in FunctionTool objects.
|
||||
We call the underlying function via .fn to test the tool logic directly.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_config(self, tmp_path, monkeypatch):
|
||||
"""Mock get_project_dir for all tool tests."""
|
||||
import mcp_server.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "_project_dir", tmp_path)
|
||||
monkeypatch.setattr(config_mod, "_auto_claude_dir", tmp_path / ".auto-claude")
|
||||
self.project_dir = tmp_path
|
||||
self.specs_dir = tmp_path / ".auto-claude" / "specs"
|
||||
|
||||
def test_task_list_empty(self):
|
||||
from mcp_server.tools.tasks import task_list
|
||||
|
||||
result = task_list.fn()
|
||||
assert result["count"] == 0
|
||||
assert result["tasks"] == []
|
||||
|
||||
def test_task_list_with_tasks(self):
|
||||
from mcp_server.tools.tasks import task_list
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-hello",
|
||||
plan={"feature": "Hello", "status": "pending", "description": "A task"},
|
||||
)
|
||||
result = task_list.fn()
|
||||
assert result["count"] == 1
|
||||
assert result["tasks"][0]["spec_id"] == "001-hello"
|
||||
assert result["tasks"][0]["title"] == "Hello"
|
||||
assert result["tasks"][0]["status"] == "pending"
|
||||
|
||||
def test_task_list_description_preview_truncated(self):
|
||||
from mcp_server.tools.tasks import task_list
|
||||
|
||||
long_desc = "x" * 300
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F", "description": long_desc},
|
||||
)
|
||||
result = task_list.fn()
|
||||
preview = result["tasks"][0]["description_preview"]
|
||||
assert len(preview) <= 204 # 200 + "..."
|
||||
assert preview.endswith("...")
|
||||
|
||||
def test_task_create_success(self):
|
||||
from mcp_server.tools.tasks import task_create
|
||||
|
||||
result = task_create.fn("New Task", "Do something")
|
||||
assert result["success"] is True
|
||||
assert "task" in result
|
||||
assert result["task"]["title"] == "New Task"
|
||||
|
||||
def test_task_create_empty_title(self):
|
||||
from mcp_server.tools.tasks import task_create
|
||||
|
||||
result = task_create.fn("", "Desc")
|
||||
assert "error" in result
|
||||
assert "Title" in result["error"]
|
||||
|
||||
def test_task_create_empty_description(self):
|
||||
from mcp_server.tools.tasks import task_create
|
||||
|
||||
result = task_create.fn("Title", "")
|
||||
assert "error" in result
|
||||
assert "Description" in result["error"]
|
||||
|
||||
def test_task_create_whitespace_title(self):
|
||||
from mcp_server.tools.tasks import task_create
|
||||
|
||||
result = task_create.fn(" ", "Desc")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_create_whitespace_description(self):
|
||||
from mcp_server.tools.tasks import task_create
|
||||
|
||||
result = task_create.fn("Title", " ")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_get_success(self):
|
||||
from mcp_server.tools.tasks import task_get
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F", "status": "pending"},
|
||||
)
|
||||
result = task_get.fn("001-feat")
|
||||
assert "task" in result
|
||||
assert result["task"]["spec_id"] == "001-feat"
|
||||
|
||||
def test_task_get_not_found(self):
|
||||
from mcp_server.tools.tasks import task_get
|
||||
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = task_get.fn("999-nope")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_update_success(self):
|
||||
from mcp_server.tools.tasks import task_update
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "Old", "status": "pending"},
|
||||
)
|
||||
result = task_update.fn("001-feat", title="New Title", status="in_progress")
|
||||
assert result["success"] is True
|
||||
assert result["task"]["title"] == "New Title"
|
||||
assert result["task"]["status"] == "in_progress"
|
||||
|
||||
def test_task_update_invalid_status(self):
|
||||
from mcp_server.tools.tasks import task_update
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F", "status": "pending"},
|
||||
)
|
||||
result = task_update.fn("001-feat", status="bogus")
|
||||
assert "error" in result
|
||||
assert "Invalid status" in result["error"]
|
||||
|
||||
def test_task_update_not_found(self):
|
||||
from mcp_server.tools.tasks import task_update
|
||||
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = task_update.fn("999-nope", title="X")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_delete_success(self):
|
||||
from mcp_server.tools.tasks import task_delete
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F"},
|
||||
)
|
||||
result = task_delete.fn("001-feat")
|
||||
assert result["success"] is True
|
||||
assert not (self.specs_dir / "001-feat").exists()
|
||||
|
||||
def test_task_delete_not_found(self):
|
||||
from mcp_server.tools.tasks import task_delete
|
||||
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = task_delete.fn("999-nope")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_update_status_success(self):
|
||||
from mcp_server.tools.tasks import task_update_status
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F", "status": "pending"},
|
||||
)
|
||||
result = task_update_status.fn("001-feat", "done")
|
||||
assert result["success"] is True
|
||||
assert result["task"]["status"] == "done"
|
||||
|
||||
def test_task_update_status_invalid(self):
|
||||
from mcp_server.tools.tasks import task_update_status
|
||||
|
||||
_make_spec_dir(
|
||||
self.specs_dir,
|
||||
"001-feat",
|
||||
plan={"feature": "F", "status": "pending"},
|
||||
)
|
||||
result = task_update_status.fn("001-feat", "invalid")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_update_status_not_found(self):
|
||||
from mcp_server.tools.tasks import task_update_status
|
||||
|
||||
self.specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = task_update_status.fn("999-nope", "done")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestTaskToolsProjectNotInitialized:
|
||||
"""Test that tools return errors when project is not initialized."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_uninitialized(self, monkeypatch):
|
||||
"""Make get_project_dir raise RuntimeError."""
|
||||
import mcp_server.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "_project_dir", None)
|
||||
|
||||
def test_task_list_error(self):
|
||||
from mcp_server.tools.tasks import task_list
|
||||
|
||||
result = task_list.fn()
|
||||
assert "error" in result
|
||||
|
||||
def test_task_create_error(self):
|
||||
from mcp_server.tools.tasks import task_create
|
||||
|
||||
result = task_create.fn("Title", "Desc")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_get_error(self):
|
||||
from mcp_server.tools.tasks import task_get
|
||||
|
||||
result = task_get.fn("001")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_update_error(self):
|
||||
from mcp_server.tools.tasks import task_update
|
||||
|
||||
result = task_update.fn("001", title="X")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_delete_error(self):
|
||||
from mcp_server.tools.tasks import task_delete
|
||||
|
||||
result = task_delete.fn("001")
|
||||
assert "error" in result
|
||||
|
||||
def test_task_update_status_error(self):
|
||||
from mcp_server.tools.tasks import task_update_status
|
||||
|
||||
result = task_update_status.fn("001", "done")
|
||||
assert "error" in result
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Tests for mcp_server/services/workspace_service.py
|
||||
====================================================
|
||||
|
||||
Tests worktree listing, diff retrieval (with truncation), merge strategies,
|
||||
discard, and PR creation. WorktreeManager is fully mocked.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to sys.path
|
||||
_backend = Path(__file__).parent.parent / "apps" / "backend"
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Pre-mock SDK modules before any mcp_server imports
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = MagicMock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
def _mock_worktree_info(
|
||||
spec_name="001-feat",
|
||||
branch="auto-claude/001-feat",
|
||||
path="/tmp/wt/001-feat",
|
||||
base_branch="main",
|
||||
is_active=True,
|
||||
commit_count=3,
|
||||
files_changed=5,
|
||||
additions=100,
|
||||
deletions=20,
|
||||
days_since_last_commit=1,
|
||||
last_commit_date=None,
|
||||
):
|
||||
"""Create a mock worktree info object."""
|
||||
info = MagicMock()
|
||||
info.spec_name = spec_name
|
||||
info.branch = branch
|
||||
info.path = Path(path)
|
||||
info.base_branch = base_branch
|
||||
info.is_active = is_active
|
||||
info.commit_count = commit_count
|
||||
info.files_changed = files_changed
|
||||
info.additions = additions
|
||||
info.deletions = deletions
|
||||
info.days_since_last_commit = days_since_last_commit
|
||||
info.last_commit_date = last_commit_date
|
||||
return info
|
||||
|
||||
|
||||
class TestListWorktrees:
|
||||
"""Tests for WorkspaceService.list_worktrees()."""
|
||||
|
||||
def test_returns_worktree_list(self, tmp_path):
|
||||
"""list_worktrees returns formatted worktree data."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
wt = _mock_worktree_info()
|
||||
mock_manager.list_all_worktrees.return_value = [wt]
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.list_worktrees()
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["worktrees"][0]["spec_name"] == "001-feat"
|
||||
assert result["worktrees"][0]["branch"] == "auto-claude/001-feat"
|
||||
assert result["worktrees"][0]["is_active"] is True
|
||||
|
||||
def test_returns_empty_list(self, tmp_path):
|
||||
"""list_worktrees returns empty list when no worktrees exist."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.list_all_worktrees.return_value = []
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.list_worktrees()
|
||||
|
||||
assert result["count"] == 0
|
||||
assert result["worktrees"] == []
|
||||
|
||||
def test_handles_import_error(self, tmp_path):
|
||||
"""list_worktrees returns error when backend unavailable."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
|
||||
with patch.object(svc, "_get_manager", side_effect=ImportError("no module")):
|
||||
result = svc.list_worktrees()
|
||||
|
||||
assert "error" in result
|
||||
|
||||
def test_handles_manager_exception(self, tmp_path):
|
||||
"""list_worktrees returns error on unexpected manager exception."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.list_all_worktrees.side_effect = RuntimeError("git error")
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.list_worktrees()
|
||||
|
||||
assert "error" in result
|
||||
|
||||
def test_includes_optional_fields(self, tmp_path):
|
||||
"""list_worktrees includes days_since_last_commit when available."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
dt = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
wt = _mock_worktree_info(days_since_last_commit=5, last_commit_date=dt)
|
||||
mock_manager.list_all_worktrees.return_value = [wt]
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.list_worktrees()
|
||||
|
||||
entry = result["worktrees"][0]
|
||||
assert entry["days_since_last_commit"] == 5
|
||||
assert "last_commit_date" in entry
|
||||
|
||||
def test_omits_none_optional_fields(self, tmp_path):
|
||||
"""list_worktrees omits optional fields when None."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
wt = _mock_worktree_info(days_since_last_commit=None, last_commit_date=None)
|
||||
mock_manager.list_all_worktrees.return_value = [wt]
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.list_worktrees()
|
||||
|
||||
entry = result["worktrees"][0]
|
||||
assert "days_since_last_commit" not in entry
|
||||
assert "last_commit_date" not in entry
|
||||
|
||||
|
||||
class TestGetDiff:
|
||||
"""Tests for WorkspaceService.get_diff()."""
|
||||
|
||||
def test_returns_diff_content(self, tmp_path):
|
||||
"""get_diff returns diff data with changed files."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
info = _mock_worktree_info()
|
||||
mock_manager.get_worktree_info.return_value = info
|
||||
mock_manager.get_changed_files.return_value = [("M", "src/main.py")]
|
||||
mock_manager.get_change_summary.return_value = "1 file changed"
|
||||
|
||||
mock_git_result = MagicMock()
|
||||
mock_git_result.returncode = 0
|
||||
mock_git_result.stdout = "diff --git a/src/main.py b/src/main.py\n+new line"
|
||||
|
||||
# run_git is imported locally inside get_diff via `from core.git_executable import run_git`
|
||||
mock_git_module = MagicMock()
|
||||
mock_git_module.run_git = MagicMock(return_value=mock_git_result)
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager), \
|
||||
patch.dict(sys.modules, {"core.git_executable": mock_git_module}):
|
||||
result = svc.get_diff("001-feat")
|
||||
|
||||
assert result["spec_id"] == "001-feat"
|
||||
assert len(result["changed_files"]) == 1
|
||||
assert result["changed_files"][0]["status"] == "M"
|
||||
|
||||
def test_returns_error_for_missing_worktree(self, tmp_path):
|
||||
"""get_diff returns error when worktree not found."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_worktree_info.return_value = None
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.get_diff("999-missing")
|
||||
|
||||
assert "error" in result
|
||||
assert "No worktree" in result["error"]
|
||||
|
||||
def test_handles_import_error(self, tmp_path):
|
||||
"""get_diff returns error when backend unavailable."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
|
||||
with patch.object(svc, "_get_manager", side_effect=ImportError("no module")):
|
||||
result = svc.get_diff("001-feat")
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestMerge:
|
||||
"""Tests for WorkspaceService.merge()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_auto_strategy(self, tmp_path):
|
||||
"""merge with 'auto' strategy calls merge_worktree without no_commit."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.merge_worktree.return_value = True
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.merge("001-feat", strategy="auto")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["strategy"] == "auto"
|
||||
mock_manager.merge_worktree.assert_called_once_with(
|
||||
"001-feat", delete_after=False, no_commit=False
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_no_commit_strategy(self, tmp_path):
|
||||
"""merge with 'no-commit' strategy passes no_commit=True."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.merge_worktree.return_value = True
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.merge("001-feat", strategy="no-commit")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_manager.merge_worktree.assert_called_once_with(
|
||||
"001-feat", delete_after=False, no_commit=True
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_failure(self, tmp_path):
|
||||
"""merge returns success=False when merge_worktree returns False."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.merge_worktree.return_value = False
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.merge("001-feat")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_handles_exception(self, tmp_path):
|
||||
"""merge returns error on unexpected exception."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.merge_worktree.side_effect = RuntimeError("conflict")
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.merge("001-feat")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_handles_import_error(self, tmp_path):
|
||||
"""merge returns error when backend unavailable."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
|
||||
with patch.object(svc, "_get_manager", side_effect=ImportError("no module")):
|
||||
result = await svc.merge("001-feat")
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestDiscard:
|
||||
"""Tests for WorkspaceService.discard()."""
|
||||
|
||||
def test_discard_success(self, tmp_path):
|
||||
"""discard calls remove_worktree and returns success."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.discard("001-feat")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_manager.remove_worktree.assert_called_once_with(
|
||||
"001-feat", delete_branch=True
|
||||
)
|
||||
|
||||
def test_discard_handles_exception(self, tmp_path):
|
||||
"""discard returns error on unexpected exception."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.remove_worktree.side_effect = RuntimeError("git error")
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = svc.discard("001-feat")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_discard_handles_import_error(self, tmp_path):
|
||||
"""discard returns error when backend unavailable."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
|
||||
with patch.object(svc, "_get_manager", side_effect=ImportError("no module")):
|
||||
result = svc.discard("001-feat")
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestCreatePR:
|
||||
"""Tests for WorkspaceService.create_pr()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_success(self, tmp_path):
|
||||
"""create_pr returns success with PR URL."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.push_and_create_pr.return_value = {
|
||||
"success": True,
|
||||
"pr_url": "https://github.com/org/repo/pull/42",
|
||||
"branch": "auto-claude/001-feat",
|
||||
"provider": "github",
|
||||
}
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.create_pr("001-feat", title="Add feature")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["pr_url"] == "https://github.com/org/repo/pull/42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_failure(self, tmp_path):
|
||||
"""create_pr returns error from manager."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.push_and_create_pr.return_value = {
|
||||
"success": False,
|
||||
"error": "Remote rejected push",
|
||||
}
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.create_pr("001-feat")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_handles_exception(self, tmp_path):
|
||||
"""create_pr returns error on unexpected exception."""
|
||||
svc = WorkspaceService(tmp_path)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.push_and_create_pr.side_effect = RuntimeError("network error")
|
||||
|
||||
with patch.object(svc, "_get_manager", return_value=mock_manager):
|
||||
result = await svc.create_pr("001-feat")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
Reference in New Issue
Block a user