diff --git a/codeframe/ui/routers/agents.py b/codeframe/ui/routers/agents.py deleted file mode 100644 index b35d6e2e..00000000 --- a/codeframe/ui/routers/agents.py +++ /dev/null @@ -1,748 +0,0 @@ -"""Agent lifecycle and management router. - -This module provides API endpoints for: -- Starting/stopping/pausing/resuming agents -- Agent-project assignments (multi-agent per project) -- Agent status and information -""" - -import logging -import os -import time -from typing import List - -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query, Request -from fastapi.responses import JSONResponse - -from codeframe.core.models import ProjectStatus -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import running_agents, start_agent, manager -from codeframe.ui.services.agent_service import AgentService -from codeframe.agents.lead_agent import LeadAgent -from codeframe.ui.models import ( - AgentAssignmentRequest, - AgentRoleUpdateRequest, - AgentAssignmentResponse, - ProjectAssignmentResponse, - AgentStartResponse, - ErrorResponse, -) -from codeframe.lib.rate_limiter import rate_limit_standard, rate_limit_ai - - -logger = logging.getLogger(__name__) - - -router = APIRouter(prefix="/api", tags=["agents"]) - - -@router.post( - "/projects/{project_id}/start", - status_code=202, - response_model=AgentStartResponse, - summary="Start Lead Agent for project", - description="Starts the Lead Agent for a project, initiating the discovery phase. " - "Returns 202 Accepted immediately while the agent starts in the background. " - "Idempotent: if the project is already running, returns current discovery status. " - "If discovery is 'idle', restarts discovery; if 'discovering' or 'completed', returns that status.", - responses={ - 200: {"model": AgentStartResponse, "description": "Discovery already in progress or completed"}, - 202: {"model": AgentStartResponse, "description": "Agent starting in background"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - 500: {"model": ErrorResponse, "description": "ANTHROPIC_API_KEY not configured"}, - }, -) -@rate_limit_ai() -async def start_project_agent( - request: Request, - project_id: int, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Start Lead Agent for a project (cf-10.2). - - Returns 202 Accepted immediately and starts agent in background. - Checks discovery state when project is already running - if discovery - is "idle", starts discovery; if "discovering" or "completed", returns - appropriate status. - - Args: - project_id: Project ID to start agent for - background_tasks: FastAPI background tasks - db: Database connection - current_user: Authenticated user - - Returns: - 202 Accepted: Starting discovery (even if project status is "running" but discovery is "idle") - 200 OK: Discovery already in progress or completed - 404 Not Found: Project doesn't exist - 403 Forbidden: User lacks project access - 500 Internal Server Error: API key not configured - - Raises: - HTTPException: 403 if unauthorized, 404 if project not found, 500 if API key not configured - """ - # cf-10.2: Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # cf-10.2: Handle idempotent behavior - check discovery state if already running - if project["status"] == ProjectStatus.RUNNING.value: - # Check discovery state before returning "already running" - # If discovery is idle, we should still start discovery - api_key = os.environ.get("ANTHROPIC_API_KEY") - if api_key: - try: - # First check if agent already exists in running_agents to avoid duplicate instantiation - existing_agent = running_agents.get(project_id) - if existing_agent: - # Reuse existing agent for status check - discovery_status = existing_agent.get_discovery_status() - else: - # Create temporary agent only for status check (will be replaced by start_agent if needed) - temp_agent = LeadAgent(project_id=project_id, db=db, api_key=api_key) - discovery_status = temp_agent.get_discovery_status() - - discovery_state = discovery_status.get("state", "idle") - - if discovery_state == "idle": - # Discovery not started - proceed to start discovery - logger.info(f"Project {project_id} is running but discovery is idle - starting discovery") - # Broadcast immediate feedback before background task starts - await manager.broadcast( - { - "type": "discovery_starting", - "project_id": project_id, - "status": "starting", - "timestamp": time.time(), - }, - project_id=project_id - ) - background_tasks.add_task(start_agent, project_id, db, running_agents, api_key) - return {"message": f"Starting discovery for project {project_id}", "status": "starting"} - elif discovery_state == "discovering": - # Discovery already in progress - return JSONResponse( - status_code=200, - content={"message": f"Project {project_id} discovery already in progress", "status": "running"}, - ) - elif discovery_state == "completed": - # Discovery completed - return JSONResponse( - status_code=200, - content={"message": f"Project {project_id} discovery already completed", "status": "completed"}, - ) - except Exception as e: - logger.warning(f"Failed to check discovery status for project {project_id}: {e}") - # Fall back to normal start flow on error - try to start discovery - # This is safer than blocking since start_agent handles duplicates gracefully - background_tasks.add_task(start_agent, project_id, db, running_agents, api_key) - return {"message": f"Starting discovery for project {project_id}", "status": "starting"} - else: - # No API key available - can't check discovery status or start - return JSONResponse( - status_code=200, - content={"message": f"Project {project_id} is already running", "status": "running"}, - ) - - # cf-10.2: Get API key from environment - api_key = os.environ.get("ANTHROPIC_API_KEY") - if not api_key: - raise HTTPException(status_code=500, detail="ANTHROPIC_API_KEY not configured") - - # Broadcast immediate feedback before background task starts - await manager.broadcast( - { - "type": "discovery_starting", - "project_id": project_id, - "status": "starting", - "timestamp": time.time(), - }, - project_id=project_id - ) - - # cf-10.2: Start agent in background task (non-blocking) - background_tasks.add_task(start_agent, project_id, db, running_agents, api_key) - - # cf-10.2: Return 202 Accepted immediately - return {"message": f"Starting Lead Agent for project {project_id}", "status": "starting"} - - -@router.post( - "/projects/{project_id}/pause", - summary="Pause project execution", - description="Pauses all agent execution for a project. Running agents will complete their current step " - "then pause. The project status changes to 'paused'. Safe to call even if no agent is running.", - responses={ - 200: {"description": "Project paused successfully"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_standard() -async def pause_project( - request: Request, - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Pause project execution. - - Args: - project_id: Project ID to pause - db: Database connection - - Returns: - Success message - - Raises: - HTTPException: 404 if project not found - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Use AgentService to pause agent - agent_service = AgentService(db=db, running_agents=running_agents) - paused = await agent_service.pause_agent(project_id) - - if paused: - return {"success": True, "message": "Project paused"} - else: - # No agent running - just update status - db.update_project(project_id, {"status": ProjectStatus.PAUSED}) - return {"success": True, "message": "Project paused (no agent was running)"} - - -@router.post( - "/projects/{project_id}/resume", - summary="Resume project execution", - description="Resumes a paused project, restarting agent execution. Agents will continue with their " - "assigned tasks from where they left off. The project status changes to 'running'.", - responses={ - 200: {"description": "Project resuming"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_ai() -async def resume_project( - request: Request, - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Resume project execution. - - Args: - project_id: Project ID to resume - db: Database connection - - Returns: - Success message - - Raises: - HTTPException: 404 if project not found - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Use AgentService to resume agent - agent_service = AgentService(db=db, running_agents=running_agents) - resumed = await agent_service.resume_agent(project_id) - - if resumed: - return {"success": True, "message": "Project resuming"} - else: - # No agent running - just update status - db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - return {"success": True, "message": "Project status updated to running (no agent found)"} - - -@router.get( - "/projects/{project_id}/agents", - response_model=List[AgentAssignmentResponse], - summary="Get agents assigned to project", - description="Returns all agents currently assigned to a project with their roles, status, and metrics. " - "Use active_only=False to also include previously unassigned agents for historical view.", - responses={ - 200: {"description": "List of agent assignments"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - 500: {"model": ErrorResponse, "description": "Database error"}, - }, -) -@rate_limit_standard() -async def get_project_agents( - request: Request, - project_id: int, - active_only: bool = Query( - True, - alias="is_active", - description="If True (default), only returns currently assigned agents. " - "If False, includes historical assignments." - ), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get all agents assigned to a project. - - Multi-Agent Per Project API (Phase 3) - Updated endpoint. - - Args: - project_id: Project ID - active_only: If True, only return currently assigned agents (default: True) - db: Database connection - - Returns: - List of agents with assignment metadata - - Raises: - HTTPException: 404 if project not found, 500 on database error - """ - import json - - try: - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get agents for project using database method - agents = db.get_agents_for_project(project_id, active_only=active_only) - - # Parse metrics JSON for each agent - for agent in agents: - metrics_json = agent.get("metrics") - if metrics_json: - try: - agent["metrics"] = ( - json.loads(metrics_json) - if isinstance(metrics_json, str) - else metrics_json - ) - except (json.JSONDecodeError, TypeError): - agent["metrics"] = None - else: - agent["metrics"] = None - - return agents - except HTTPException: - raise - except Exception as e: - logger.error(f"Error fetching agents for project {project_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error fetching agents: {str(e)}") - - -@router.post( - "/projects/{project_id}/agents", - status_code=201, - summary="Assign agent to project", - description="Assigns an agent to a project with a specific role. Each agent can only have one active " - "assignment per project. Use different roles like 'primary_backend', 'frontend', 'test', etc. " - "to organize multi-agent workflows.", - responses={ - 201: {"description": "Agent assigned successfully"}, - 400: {"model": ErrorResponse, "description": "Agent already assigned to this project"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project or agent not found"}, - 500: {"model": ErrorResponse, "description": "Error assigning agent"}, - }, -) -@rate_limit_standard() -async def assign_agent_to_project( - request: Request, - project_id: int, - body: AgentAssignmentRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Assign an agent to a project. - - Multi-Agent Per Project API (Phase 3) - New endpoint. - - Args: - project_id: Project ID - request: Agent assignment request with agent_id and role - db: Database connection - - Returns: - dict with assignment_id and success message - - Raises: - HTTPException: 400 if agent already assigned, 404 if project/agent not found, 500 on error - """ - try: - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Verify agent exists - agent = db.get_agent(body.agent_id) - if not agent: - raise HTTPException(status_code=404, detail=f"Agent {body.agent_id} not found") - - # Check if agent is already assigned (active) - existing = db.get_agent_assignment(project_id, body.agent_id) - if existing and existing.get("is_active"): - raise HTTPException( - status_code=400, - detail=f"Agent {body.agent_id} is already assigned to project {project_id}", - ) - - # Assign agent to project - assignment_id = db.assign_agent_to_project( - project_id=project_id, agent_id=body.agent_id, role=body.role - ) - - logger.info( - f"Assigned agent {body.agent_id} to project {project_id} with role {body.role}" - ) - - return { - "assignment_id": assignment_id, - "message": f"Agent {body.agent_id} assigned to project {project_id} with role {body.role}", - } - except HTTPException: - raise - except Exception as e: - logger.error(f"Error assigning agent to project: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error assigning agent: {str(e)}") - - -@router.delete( - "/projects/{project_id}/agents/{agent_id}", - status_code=204, - summary="Remove agent from project", - description="Removes an agent from a project (soft delete). The assignment record is preserved " - "with an unassigned_at timestamp for audit purposes. The agent can be re-assigned later.", - responses={ - 204: {"description": "Agent removed (no content)"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "No active assignment found for this agent/project"}, - 500: {"model": ErrorResponse, "description": "Error removing agent"}, - }, -) -@rate_limit_standard() -async def remove_agent_from_project( - request: Request, - project_id: int, - agent_id: str, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Remove an agent from a project (soft delete). - - Multi-Agent Per Project API (Phase 3) - New endpoint. - - Args: - project_id: Project ID - agent_id: Agent ID to remove - db: Database connection - - Returns: - No content (204) on success - - Raises: - HTTPException: 404 if assignment not found, 500 on error - """ - try: - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Remove agent from project - rows_affected = db.remove_agent_from_project(project_id, agent_id) - - if rows_affected == 0: - raise HTTPException( - status_code=404, - detail=f"No active assignment found for agent {agent_id} on project {project_id}", - ) - - logger.info(f"Removed agent {agent_id} from project {project_id}") - - return None # 204 No Content - except HTTPException: - raise - except Exception as e: - logger.error(f"Error removing agent from project: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error removing agent: {str(e)}") - - -@router.put( - "/projects/{project_id}/agents/{agent_id}/role", - response_model=AgentAssignmentResponse, - summary="Update agent role on project", - description="Updates the role of an assigned agent on a project. Use this to reassign agents " - "to different responsibilities without removing and re-adding them.", - responses={ - 200: {"model": AgentAssignmentResponse, "description": "Role updated, returns full assignment details"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "No active assignment found"}, - 500: {"model": ErrorResponse, "description": "Error updating agent role"}, - }, -) -@rate_limit_standard() -async def update_agent_role( - request: Request, - project_id: int, - agent_id: str, - body: AgentRoleUpdateRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Update an agent's role on a project. - - Multi-Agent Per Project API (Phase 3) - New endpoint. - - Args: - project_id: Project ID - agent_id: Agent ID - request: New role for the agent - db: Database connection - - Returns: - AgentAssignmentResponse with updated assignment details - - Raises: - HTTPException: 404 if assignment not found, 500 on error - """ - try: - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Update agent role - rows_affected = db.reassign_agent_role( - project_id=project_id, agent_id=agent_id, new_role=body.role - ) - - if rows_affected == 0: - raise HTTPException( - status_code=404, - detail=f"No active assignment found for agent {agent_id} on project {project_id}", - ) - - logger.info(f"Updated agent {agent_id} role to {body.role} on project {project_id}") - - # Fetch assignment details (junction table fields only) - assignment = db.get_agent_assignment(project_id, agent_id) - if not assignment: - raise HTTPException( - status_code=404, - detail=f"Failed to retrieve updated assignment for agent {agent_id}", - ) - - # Fetch full agent details (type, provider, status, etc.) - agent = db.get_agent(agent_id) - if not agent: - raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") - - # Merge assignment and agent data to create full AgentAssignmentResponse - full_assignment = { - # From agents table - "agent_id": agent["id"], - "type": agent["type"], - "provider": agent.get("provider"), - "maturity_level": agent.get("maturity_level"), - "status": agent.get("status"), - "current_task_id": agent.get("current_task_id"), - "last_heartbeat": agent.get("last_heartbeat"), - # From project_agents table - "assignment_id": assignment["id"], - "role": assignment["role"], - "assigned_at": assignment["assigned_at"], - "unassigned_at": assignment.get("unassigned_at"), - "is_active": assignment["is_active"], - } - - return full_assignment - except HTTPException: - raise - except Exception as e: - logger.error(f"Error updating agent role: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error updating agent role: {str(e)}") - - -@router.patch( - "/projects/{project_id}/agents/{agent_id}", - summary="Update agent role (PATCH)", - description="Updates the role of an assigned agent on a project. This is a PATCH variant of " - "the PUT endpoint, returning a simple success message instead of full assignment details.", - responses={ - 200: {"description": "Role updated successfully"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "No active assignment found"}, - 422: {"model": ErrorResponse, "description": "Validation error in request body"}, - 500: {"model": ErrorResponse, "description": "Error updating agent role"}, - }, -) -@rate_limit_standard() -async def patch_agent_role( - request: Request, - project_id: int, - agent_id: str, - body: AgentRoleUpdateRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Update an agent's role on a project (PATCH variant). - - Multi-Agent Per Project API (Phase 3) - PATCH endpoint for consistency with tests. - - Args: - project_id: Project ID - agent_id: Agent ID - body: New role for the agent - db: Database connection - - Returns: - dict with success message - - Raises: - HTTPException: 404 if assignment not found, 422 for validation errors, 500 on error - """ - try: - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Update agent role - rows_affected = db.reassign_agent_role( - project_id=project_id, agent_id=agent_id, new_role=body.role - ) - - if rows_affected == 0: - raise HTTPException( - status_code=404, - detail=f"No active assignment found for agent {agent_id} on project {project_id}", - ) - - logger.info(f"Updated agent {agent_id} role to {body.role} on project {project_id}") - - return { - "message": f"Agent {agent_id} role updated to {body.role} on project {project_id}" - } - except HTTPException: - raise - except Exception as e: - logger.error(f"Error updating agent role: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error updating agent role: {str(e)}") - - -@router.get( - "/agents/{agent_id}/projects", - response_model=List[ProjectAssignmentResponse], - summary="Get projects for agent", - description="Returns all projects an agent is assigned to, with the agent's role in each project. " - "Results are filtered to only include projects the authenticated user has access to. " - "Use active_only=False to include historical assignments.", - responses={ - 200: {"description": "List of project assignments"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 404: {"model": ErrorResponse, "description": "Agent not found"}, - 500: {"model": ErrorResponse, "description": "Database error"}, - }, -) -@rate_limit_standard() -async def get_agent_projects( - request: Request, - agent_id: str, - active_only: bool = Query( - True, - description="If True (default), only returns active assignments. If False, includes historical." - ), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get all projects an agent is assigned to. - - Multi-Agent Per Project API (Phase 3) - New endpoint. - - Args: - agent_id: Agent ID - active_only: If True, only return active assignments (default: True) - db: Database connection - current_user: Authenticated user - - Returns: - List of projects with assignment metadata (filtered by user access) - - Raises: - HTTPException: 404 if agent not found, 500 on database error - """ - try: - # Verify agent exists - agent = db.get_agent(agent_id) - if not agent: - raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") - - # Get projects for agent using database method - projects = db.get_projects_for_agent(agent_id, active_only=active_only) - - # Security: Filter to only include projects the user has access to - filtered_projects = [ - project for project in projects - if db.user_has_project_access(current_user.id, project["project_id"]) - ] - - return filtered_projects - except HTTPException: - raise - except Exception as e: - logger.error(f"Error fetching projects for agent {agent_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error fetching projects: {str(e)}") diff --git a/codeframe/ui/routers/blockers.py b/codeframe/ui/routers/blockers.py deleted file mode 100644 index 3060f1fb..00000000 --- a/codeframe/ui/routers/blockers.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Blocker management router. - -This module handles blocker-related endpoints for human-in-the-loop -intervention, including listing blockers, resolving them, and getting -blocker metrics. -""" - -import logging -from typing import Optional - -from fastapi import APIRouter, HTTPException, Depends, Query -from fastapi.responses import JSONResponse - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager -from codeframe.core.models import BlockerResolve -from codeframe.ui.models import ( - BlockerResponse, - BlockerListResponse, - BlockerMetricsResponse, - ErrorResponse, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/projects/{project_id}/blockers", tags=["blockers"]) - - -@router.get( - "", - response_model=BlockerListResponse, - summary="Get project blockers", - description="Returns all blockers for a project with optional status filtering. " - "Blockers are human-in-the-loop intervention points where agents need guidance. " - "SYNC blockers pause execution; ASYNC blockers allow work to continue with workarounds.", - responses={ - 200: {"model": BlockerListResponse, "description": "List of blockers with counts"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -async def get_project_blockers( - project_id: int, - status: Optional[str] = Query( - default=None, - description="Filter by status: 'PENDING' (awaiting answer), 'RESOLVED' (answered), 'EXPIRED' (timed out)", - example="PENDING" - ), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get blockers for a project (049-human-in-loop). - - Args: - project_id: Project ID - status: Optional filter by status ('PENDING', 'RESOLVED', 'EXPIRED') - db: Database instance (injected) - - Returns: - BlockerListResponse dictionary with: - - blockers: List of blocker dictionaries - - total: Total number of blockers - - pending_count: Number of pending blockers - - sync_count: Number of SYNC blockers - - async_count: Number of ASYNC blockers - - Raises: - HTTPException: - - 404: Project not found - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get blockers from database - blockers_data = db.list_blockers(project_id, status) - - return blockers_data - - -@router.get( - "/metrics", - response_model=BlockerMetricsResponse, - summary="Get blocker metrics", - description="Returns analytics on blocker resolution for a project, including average resolution time, " - "expiration rate, and counts by status/type. Useful for understanding human-in-the-loop efficiency.", - responses={ - 200: {"model": BlockerMetricsResponse, "description": "Blocker metrics"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -async def get_blocker_metrics_endpoint( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get blocker metrics for a project (049-human-in-loop, Phase 10/T062). - - Provides analytics on blocker resolution times and expiration rates. - - Args: - project_id: Project ID to get metrics for - db: Database instance (injected) - - Returns: - 200 OK: Blocker metrics - { - "avg_resolution_time_seconds": float | null, - "expiration_rate_percent": float, - "total_blockers": int, - "resolved_count": int, - "expired_count": int, - "pending_count": int, - "sync_count": int, - "async_count": int - } - - 404 Not Found: Project doesn't exist - { - "error": "Project not found", - "project_id": int - } - - Raises: - HTTPException: - - 404: Project not found - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, detail={"error": "Project not found", "project_id": project_id} - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get metrics - metrics = db.get_blocker_metrics(project_id) - return metrics - - -# Individual blocker endpoints (not scoped to project) -blocker_router = APIRouter(prefix="/api/blockers", tags=["blockers"]) - - -@blocker_router.get( - "/{blocker_id}", - response_model=BlockerResponse, - summary="Get blocker details", - description="Returns detailed information about a specific blocker, including its type, status, " - "description, and resolution (if any). Requires access to the blocker's parent project.", - responses={ - 200: {"model": BlockerResponse, "description": "Blocker details"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied to blocker's project"}, - 404: {"model": ErrorResponse, "description": "Blocker not found"}, - }, -) -async def get_blocker( - blocker_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get details of a specific blocker (049-human-in-loop). - - Args: - blocker_id: Blocker ID - db: Database instance (injected) - - Returns: - Blocker dictionary - - Raises: - HTTPException: - - 404: Blocker not found - """ - blocker = db.get_blocker(blocker_id) - - if not blocker: - raise HTTPException(status_code=404, detail=f"Blocker {blocker_id} not found") - - # Authorization check - verify user has access to the blocker's project - project_id = blocker.get("project_id") - if project_id and not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - return blocker - - -@blocker_router.post( - "/{blocker_id}/resolve", - summary="Resolve a blocker", - description="Provides an answer to resolve a blocker, allowing the agent to continue. " - "The answer is stored with the blocker and agents can use it to proceed. " - "Returns 409 Conflict if the blocker was already resolved.", - responses={ - 200: {"description": "Blocker resolved successfully"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Blocker not found"}, - 409: {"description": "Blocker already resolved"}, - 422: {"model": ErrorResponse, "description": "Invalid request body"}, - }, -) -async def resolve_blocker_endpoint( - blocker_id: int, - request: BlockerResolve, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Resolve a blocker with user's answer (049-human-in-loop, Phase 4/US2). - - Args: - blocker_id: Blocker ID to resolve - request: BlockerResolve containing the answer - db: Database instance (injected) - - Returns: - 200 OK: Blocker resolution successful - { - "blocker_id": int, - "status": "RESOLVED", - "resolved_at": ISODate (RFC 3339) - } - - 409 Conflict: Blocker already resolved - { - "error": "Blocker already resolved", - "blocker_id": int, - "resolved_at": ISODate (RFC 3339) - } - - 404 Not Found: Blocker doesn't exist - { - "error": "Blocker not found", - "blocker_id": int - } - - Raises: - HTTPException: - - 404: Blocker not found - - 409: Blocker already resolved (duplicate resolution) - - 422: Invalid request (validation error) - """ - - # Check if blocker exists - blocker = db.get_blocker(blocker_id) - if not blocker: - raise HTTPException( - status_code=404, detail={"error": "Blocker not found", "blocker_id": blocker_id} - ) - - # Authorization check - verify user has access to the blocker's project - project_id = blocker.get("project_id") - if project_id and not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Attempt to resolve blocker (returns False if already resolved) - success = db.resolve_blocker(blocker_id, request.answer) - - if not success: - # Blocker already resolved - return 409 Conflict - blocker = db.get_blocker(blocker_id) - return JSONResponse( - status_code=409, - content={ - "error": "Blocker already resolved", - "blocker_id": blocker_id, - "resolved_at": blocker["resolved_at"], - }, - ) - - # Get updated blocker for response - blocker = db.get_blocker(blocker_id) - - # Broadcast blocker_resolved event via WebSocket - try: - await manager.broadcast( - { - "type": "blocker_resolved", - "blocker_id": blocker_id, - "answer": request.answer, - "resolved_at": blocker["resolved_at"], - } - ) - except Exception as e: - # Log error but don't fail the request - logger.error(f"Failed to broadcast blocker_resolved event: {e}") - - # Return success response - return {"blocker_id": blocker_id, "status": "RESOLVED", "resolved_at": blocker["resolved_at"]} diff --git a/codeframe/ui/routers/chat.py b/codeframe/ui/routers/chat.py deleted file mode 100644 index 57173e88..00000000 --- a/codeframe/ui/routers/chat.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Chat API router for CodeFRAME. - -This module provides endpoints for chatting with the Lead Agent and -retrieving conversation history. - -Endpoints: - - POST /api/projects/{project_id}/chat - Send chat message to agent - - GET /api/projects/{project_id}/chat/history - Get chat history -""" - -import asyncio -from datetime import datetime, UTC -from typing import Dict - -from fastapi import APIRouter, Depends, HTTPException, Request - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager, running_agents -from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard - -# Create router for chat endpoints -router = APIRouter(prefix="/api/projects/{project_id}/chat", tags=["chat"]) - - -@router.post("") -@rate_limit_ai() -async def chat_with_lead( - request: Request, - project_id: int, - message: Dict[str, str], - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Chat with Lead Agent (cf-14.1). - - Send user message to Lead Agent and get AI response. - Broadcasts message via WebSocket for real-time updates. - - Args: - project_id: Project ID - message: Dict with 'message' key containing user message - db: Database instance (injected) - - Returns: - Dict with 'response' and 'timestamp' - - Raises: - HTTPException: - - 404: Project not found - - 400: Empty message or agent not started - - 500: Agent communication failure - """ - # Validate input - user_message = message.get("message", "").strip() - if not user_message: - raise HTTPException(status_code=400, detail="Message cannot be empty") - - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Check if Lead Agent is running - agent = running_agents.get(project_id) - if not agent: - raise HTTPException( - status_code=400, - detail="Lead Agent not started for this project. Start the agent first.", - ) - - try: - # Send message to Lead Agent (run in thread to avoid blocking event loop) - response_text = await asyncio.to_thread(agent.chat, user_message) - - # Get current timestamp - timestamp = datetime.now(UTC).isoformat().replace("+00:00", "Z") - - # Broadcast assistant response via WebSocket - try: - await manager.broadcast( - { - "type": "chat_message", - "project_id": project_id, - "role": "assistant", - "content": response_text, - "timestamp": timestamp, - } - ) - except Exception: - # Continue even if broadcast fails - pass - - return {"response": response_text, "timestamp": timestamp} - - except Exception as e: - # Log error and return 500 - raise HTTPException( - status_code=500, detail=f"Error communicating with Lead Agent: {str(e)}" - ) - - -@router.get("/history") -@rate_limit_standard() -async def get_chat_history( - request: Request, - project_id: int, - limit: int = 100, - offset: int = 0, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get conversation history for a project (cf-14.1). - - Args: - project_id: Project ID - limit: Maximum messages to return (default: 100) - offset: Number of messages to skip (default: 0) - db: Database instance (injected) - - Returns: - Dict with 'messages' list containing conversation history - - Raises: - HTTPException: - - 404: Project not found - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get conversation history from database - db_messages = db.get_conversation(project_id) - - # Apply pagination - start = offset - end = offset + limit - paginated_messages = db_messages[start:end] - - # Format messages for API response - messages = [] - for msg in paginated_messages: - # Keys are indexed like "user_0", "assistant_1" - extract role prefix - key = msg["key"] - role = key.rsplit("_", 1)[0] if "_" in key else key - messages.append( - { - "role": role, - "content": msg["value"], - "timestamp": msg["created_at"], - } - ) - - return {"messages": messages} diff --git a/codeframe/ui/routers/checkpoints.py b/codeframe/ui/routers/checkpoints.py deleted file mode 100644 index 4a5a8473..00000000 --- a/codeframe/ui/routers/checkpoints.py +++ /dev/null @@ -1,759 +0,0 @@ -"""Checkpoints router for CodeFRAME FastAPI server. - -Handles checkpoint management endpoints including creating checkpoints, listing -checkpoints, restoring from checkpoints, and getting checkpoint diffs. - -Sprint 10 - Phase 4: Checkpoint API endpoints (T092-T097): -- GET /api/projects/{project_id}/checkpoints - List all checkpoints -- POST /api/projects/{project_id}/checkpoints - Create a new checkpoint -- GET /api/projects/{project_id}/checkpoints/{checkpoint_id} - Get checkpoint details -- DELETE /api/projects/{project_id}/checkpoints/{checkpoint_id} - Delete checkpoint -- POST /api/projects/{project_id}/checkpoints/{checkpoint_id}/restore - Restore checkpoint -- GET /api/projects/{project_id}/checkpoints/{checkpoint_id}/diff - Get checkpoint diff -""" - -from fastapi import APIRouter, HTTPException, Depends, Response, Body -from fastapi.responses import JSONResponse -from pathlib import Path -from datetime import datetime, UTC -import hashlib -import logging -import re -import subprocess - -from codeframe.ui.models import ( - CheckpointCreateRequest, - CheckpointResponse, - CheckpointDiffResponse, - RestoreCheckpointRequest, -) -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager -from codeframe.persistence.database import Database -from codeframe.lib.checkpoint_manager import CheckpointManager - -# Module logger -logger = logging.getLogger(__name__) - -# Create router with prefix for all checkpoint endpoints -router = APIRouter(prefix="/api/projects/{project_id}/checkpoints", tags=["checkpoints"]) - - -@router.get("") -async def list_checkpoints( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """List all checkpoints for a project (T092). - - Sprint 10 - Phase 4: Checkpoint API - - Returns all checkpoints for the specified project, sorted by creation time - (most recent first). Includes checkpoint metadata for quick inspection. - - Args: - project_id: Project ID to list checkpoints for - db: Database connection (injected) - - Returns: - 200 OK: List of checkpoints - { - "checkpoints": [ - { - "id": int, - "project_id": int, - "name": str, - "description": str | null, - "trigger": str, - "git_commit": str, - "database_backup_path": str, - "context_snapshot_path": str, - "metadata": { - "project_id": int, - "phase": str, - "tasks_completed": int, - "tasks_total": int, - "agents_active": list[str], - "last_task_completed": str | null, - "context_items_count": int, - "total_cost_usd": float - }, - "created_at": str # ISO 8601 - }, - ... - ] - } - - 404 Not Found: Project not found - - Example: - GET /api/projects/123/checkpoints - """ - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get checkpoints from database - checkpoints = db.get_checkpoints(project_id) - - # Convert to response models - checkpoint_responses = [] - for checkpoint in checkpoints: - checkpoint_responses.append( - CheckpointResponse( - id=checkpoint.id, - project_id=checkpoint.project_id, - name=checkpoint.name, - description=checkpoint.description, - trigger=checkpoint.trigger, - git_commit=checkpoint.git_commit, - database_backup_path=checkpoint.database_backup_path, - context_snapshot_path=checkpoint.context_snapshot_path, - metadata=checkpoint.metadata.model_dump(), - created_at=checkpoint.created_at.isoformat(), - ) - ) - - return {"checkpoints": checkpoint_responses} - - -@router.post("", status_code=201) -async def create_checkpoint( - project_id: int, - request: CheckpointCreateRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Create a new checkpoint for a project (T093). - - Sprint 10 - Phase 4: Checkpoint API - - Creates a complete project checkpoint including: - - Git commit (code state) - - Database backup (tasks, context, metrics) - - Context snapshot (agent context items as JSON) - - Metadata (progress, costs, active agents) - - Args: - project_id: Project ID to create checkpoint for - request: CheckpointCreateRequest with name, description, trigger - db: Database connection (injected) - - Returns: - 201 Created: Checkpoint created successfully - { - "id": int, - "project_id": int, - "name": str, - "description": str | null, - "trigger": str, - "git_commit": str, - "database_backup_path": str, - "context_snapshot_path": str, - "metadata": { - "project_id": int, - "phase": str, - "tasks_completed": int, - "tasks_total": int, - "agents_active": list[str], - "last_task_completed": str | null, - "context_items_count": int, - "total_cost_usd": float - }, - "created_at": str # ISO 8601 - } - - 404 Not Found: Project not found - 500 Internal Server Error: Checkpoint creation failed - - Example: - POST /api/projects/123/checkpoints - Body: { - "name": "Before refactor", - "description": "Safety checkpoint before major refactoring", - "trigger": "manual" - } - """ - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get project workspace path - workspace_path = project.get("workspace_path") - if not workspace_path: - raise HTTPException( - status_code=500, - detail=f"Project {project_id} has no workspace path configured", - ) - - try: - # Create checkpoint manager - checkpoint_mgr = CheckpointManager( - db=db, - project_root=Path(workspace_path), - project_id=project_id, - ) - - # Create checkpoint - checkpoint = checkpoint_mgr.create_checkpoint( - name=request.name, - description=request.description, - trigger=request.trigger, - ) - - logger.info( - f"Created checkpoint {checkpoint.id} for project {project_id}: {checkpoint.name}" - ) - - # Broadcast checkpoint created event - try: - await manager.broadcast( - { - "type": "checkpoint_created", - "project_id": project_id, - "checkpoint_id": checkpoint.id, - "checkpoint_name": checkpoint.name, - "trigger": checkpoint.trigger, - "timestamp": datetime.now(UTC).isoformat(), - } - ) - except Exception as e: - logger.warning(f"Failed to broadcast checkpoint_created event: {e}") - - # Return checkpoint response - return CheckpointResponse( - id=checkpoint.id, - project_id=checkpoint.project_id, - name=checkpoint.name, - description=checkpoint.description, - trigger=checkpoint.trigger, - git_commit=checkpoint.git_commit, - database_backup_path=checkpoint.database_backup_path, - context_snapshot_path=checkpoint.context_snapshot_path, - metadata=checkpoint.metadata.model_dump(), - created_at=checkpoint.created_at.isoformat(), - ) - - except Exception as e: - logger.error(f"Failed to create checkpoint for project {project_id}: {e}", exc_info=True) - raise HTTPException( - status_code=500, detail="Internal server error while creating checkpoint" - ) - - -@router.get("/{checkpoint_id}") -async def get_checkpoint( - project_id: int, - checkpoint_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get details of a specific checkpoint (T094). - - Sprint 10 - Phase 4: Checkpoint API - - Returns full details of a checkpoint including all metadata. - - Args: - project_id: Project ID (for path consistency) - checkpoint_id: Checkpoint ID to retrieve - db: Database connection (injected) - - Returns: - 200 OK: Checkpoint details - { - "id": int, - "project_id": int, - "name": str, - "description": str | null, - "trigger": str, - "git_commit": str, - "database_backup_path": str, - "context_snapshot_path": str, - "metadata": { - "project_id": int, - "phase": str, - "tasks_completed": int, - "tasks_total": int, - "agents_active": list[str], - "last_task_completed": str | null, - "context_items_count": int, - "total_cost_usd": float - }, - "created_at": str # ISO 8601 - } - - 404 Not Found: Project or checkpoint not found - - Example: - GET /api/projects/123/checkpoints/42 - """ - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get checkpoint from database - checkpoint = db.get_checkpoint_by_id(checkpoint_id) - if not checkpoint: - raise HTTPException(status_code=404, detail=f"Checkpoint {checkpoint_id} not found") - - # Verify checkpoint belongs to this project - if checkpoint.project_id != project_id: - raise HTTPException( - status_code=404, - detail=f"Checkpoint {checkpoint_id} does not belong to project {project_id}", - ) - - # Return checkpoint response - return CheckpointResponse( - id=checkpoint.id, - project_id=checkpoint.project_id, - name=checkpoint.name, - description=checkpoint.description, - trigger=checkpoint.trigger, - git_commit=checkpoint.git_commit, - database_backup_path=checkpoint.database_backup_path, - context_snapshot_path=checkpoint.context_snapshot_path, - metadata=checkpoint.metadata.model_dump(), - created_at=checkpoint.created_at.isoformat(), - ) - - -@router.delete("/{checkpoint_id}", status_code=204) -async def delete_checkpoint( - project_id: int, - checkpoint_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Delete a checkpoint and its files (T095). - - Sprint 10 - Phase 4: Checkpoint API - - Deletes a checkpoint from the database and removes its backup files - (database backup and context snapshot). - - Args: - project_id: Project ID (for path consistency) - checkpoint_id: Checkpoint ID to delete - db: Database connection (injected) - - Returns: - 204 No Content: Checkpoint deleted successfully - - 404 Not Found: Project or checkpoint not found - 500 Internal Server Error: File deletion failed - - Example: - DELETE /api/projects/123/checkpoints/42 - """ - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get checkpoint from database - checkpoint = db.get_checkpoint_by_id(checkpoint_id) - if not checkpoint: - raise HTTPException(status_code=404, detail=f"Checkpoint {checkpoint_id} not found") - - # Verify checkpoint belongs to this project - if checkpoint.project_id != project_id: - raise HTTPException( - status_code=404, - detail=f"Checkpoint {checkpoint_id} does not belong to project {project_id}", - ) - - try: - # Delete backup files - db_backup_path = Path(checkpoint.database_backup_path) - context_snapshot_path = Path(checkpoint.context_snapshot_path) - - if db_backup_path.exists(): - db_backup_path.unlink() - logger.debug(f"Deleted database backup: {db_backup_path}") - - if context_snapshot_path.exists(): - context_snapshot_path.unlink() - logger.debug(f"Deleted context snapshot: {context_snapshot_path}") - - # Delete checkpoint from database - db.delete_checkpoint(checkpoint_id) - - logger.info(f"Deleted checkpoint {checkpoint_id} for project {project_id}") - - # Broadcast checkpoint deleted event - try: - await manager.broadcast( - { - "type": "checkpoint_deleted", - "project_id": project_id, - "checkpoint_id": checkpoint_id, - "timestamp": datetime.now(UTC).isoformat(), - } - ) - except Exception as e: - logger.warning(f"Failed to broadcast checkpoint_deleted event: {e}") - - # Return 204 No Content - return Response(status_code=204) - - except Exception as e: - logger.error(f"Failed to delete checkpoint {checkpoint_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Checkpoint deletion failed") - - -@router.post("/{checkpoint_id}/restore", status_code=202) -async def restore_checkpoint( - project_id: int, - checkpoint_id: int, - request: RestoreCheckpointRequest = Body(default_factory=RestoreCheckpointRequest), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Restore project to checkpoint state (T096, T097). - - Sprint 10 - Phase 4: Checkpoint API - - Restores project to a previous checkpoint state. If confirm_restore=False, - shows git diff without making changes. If confirm_restore=True, performs - the restoration including: - - Checking out git commit - - Restoring database from backup - - Restoring context items - - Args: - project_id: Project ID - checkpoint_id: Checkpoint ID to restore - request: RestoreCheckpointRequest with confirm_restore flag - db: Database connection (injected) - - Returns: - 200 OK (if confirm_restore=False): Diff preview - { - "checkpoint_name": str, - "diff": str # Git diff output - } - - 202 Accepted (if confirm_restore=True): Restore started - { - "success": bool, - "checkpoint_name": str, - "git_commit": str, - "items_restored": int - } - - 404 Not Found: Project or checkpoint not found - 500 Internal Server Error: Restore failed - - Example: - POST /api/projects/123/checkpoints/42/restore - Body: { - "confirm_restore": false # Show diff first - } - - POST /api/projects/123/checkpoints/42/restore - Body: { - "confirm_restore": true # Actually restore - } - """ - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get project workspace path - workspace_path = project.get("workspace_path") - if not workspace_path: - raise HTTPException( - status_code=500, - detail=f"Project {project_id} has no workspace path configured", - ) - - # Verify checkpoint exists - checkpoint = db.get_checkpoint_by_id(checkpoint_id) - if not checkpoint: - raise HTTPException(status_code=404, detail=f"Checkpoint {checkpoint_id} not found") - - # Verify checkpoint belongs to this project - if checkpoint.project_id != project_id: - raise HTTPException( - status_code=404, - detail=f"Checkpoint {checkpoint_id} does not belong to project {project_id}", - ) - - try: - # Create checkpoint manager - checkpoint_mgr = CheckpointManager( - db=db, - project_root=Path(workspace_path), - project_id=project_id, - ) - - # Restore checkpoint (or show diff if not confirmed) - result = checkpoint_mgr.restore_checkpoint( - checkpoint_id=checkpoint_id, - confirm=request.confirm_restore, - ) - - if request.confirm_restore: - logger.info(f"Restored checkpoint {checkpoint_id} for project {project_id}") - - # Broadcast checkpoint restored event - try: - await manager.broadcast( - { - "type": "checkpoint_restored", - "project_id": project_id, - "checkpoint_id": checkpoint_id, - "checkpoint_name": checkpoint.name, - "git_commit": result.get("git_commit"), - "files_changed": result.get("files_changed", 0), - "timestamp": datetime.now(UTC).isoformat(), - } - ) - except Exception as e: - logger.warning(f"Failed to broadcast checkpoint_restored event: {e}") - - # Return 202 Accepted for successful restore - return result - else: - # Return 200 OK for diff preview - return result - - except ValueError as e: - # Checkpoint not found or validation error - logger.error(f"Checkpoint validation error: {e}", exc_info=True) - raise HTTPException(status_code=404, detail="Checkpoint not found or invalid") - except FileNotFoundError as e: - # Backup files missing - logger.error(f"Checkpoint backup files missing: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Checkpoint backup files not found") - except Exception as e: - logger.error(f"Failed to restore checkpoint {checkpoint_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Checkpoint restore failed") - - -@router.get("/{checkpoint_id}/diff") -async def get_checkpoint_diff( - project_id: int, - checkpoint_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> CheckpointDiffResponse: - """Get git diff for a checkpoint (Sprint 10 Phase 4). - - Returns the git diff between the checkpoint commit and current HEAD, - including statistics about files changed, insertions, and deletions. - - Args: - project_id: Project ID - checkpoint_id: Checkpoint ID to get diff for - db: Database connection (injected) - - Returns: - 200 OK: Checkpoint diff with statistics - { - "files_changed": int, - "insertions": int, - "deletions": int, - "diff": str - } - 404 Not Found: Project or checkpoint not found - 500 Internal Server Error: Git operation failed - """ - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get project workspace path - workspace_path = project.get("workspace_path") - if not workspace_path: - raise HTTPException( - status_code=500, - detail=f"Project {project_id} has no workspace path configured", - ) - - # Verify checkpoint exists - checkpoint = db.get_checkpoint_by_id(checkpoint_id) - if not checkpoint: - raise HTTPException(status_code=404, detail=f"Checkpoint {checkpoint_id} not found") - - # Verify checkpoint belongs to this project - if checkpoint.project_id != project_id: - raise HTTPException( - status_code=404, - detail=f"Checkpoint {checkpoint_id} does not belong to project {project_id}", - ) - - # Verify workspace directory exists on filesystem before git operations - workspace_dir = Path(workspace_path) - if not workspace_dir.exists(): - logger.warning(f"Workspace directory not found: {workspace_path}") - raise HTTPException( - status_code=404, - detail=f"Workspace directory not found for project {project_id}. " - "The workspace may have been deleted or moved.", - ) - - # SECURITY: Validate git commit SHA format to prevent command injection - git_sha_pattern = re.compile(r"^[a-f0-9]{7,40}$") - if not git_sha_pattern.match(checkpoint.git_commit): - logger.error(f"Invalid git commit SHA format: {checkpoint.git_commit}") - raise HTTPException( - status_code=500, - detail=f"Invalid git commit format in checkpoint {checkpoint_id}", - ) - - try: - # Verify git commit exists before attempting diff - try: - subprocess.run( - ["git", "cat-file", "-e", checkpoint.git_commit], - cwd=workspace_dir, - check=True, - capture_output=True, - timeout=5, - ) - except subprocess.CalledProcessError: - logger.error(f"Git commit {checkpoint.git_commit} not found in repository") - raise HTTPException( - status_code=404, - detail=f"Checkpoint commit {checkpoint.git_commit[:7]} not found in repository", - ) - except subprocess.TimeoutExpired: - logger.error(f"Git verification timed out for commit {checkpoint.git_commit}") - raise HTTPException(status_code=500, detail="Git operation timed out") - - # Create checkpoint manager - checkpoint_mgr = CheckpointManager( - db=db, - project_root=workspace_dir, - project_id=project_id, - ) - - # Get diff output with size limit (10MB) - diff_output = checkpoint_mgr._show_diff(checkpoint.git_commit) - MAX_DIFF_SIZE = 10 * 1024 * 1024 # 10MB - if len(diff_output) > MAX_DIFF_SIZE: - diff_output = ( - diff_output[:MAX_DIFF_SIZE] + "\n\n... [diff truncated - exceeded 10MB limit]" - ) - logger.warning(f"Diff for checkpoint {checkpoint_id} truncated due to size limit") - - # Parse diff statistics using git diff --numstat - try: - stats_result = subprocess.run( - ["git", "diff", "--numstat", checkpoint.git_commit, "HEAD"], - cwd=workspace_dir, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - - # Parse numstat output - # Format: \t\t - files_changed = 0 - total_insertions = 0 - total_deletions = 0 - binary_files = 0 - - for line in stats_result.stdout.strip().split("\n"): - if not line: - continue - files_changed += 1 - parts = line.split("\t") - if len(parts) >= 2: - # Handle binary files (marked as '-') - if parts[0] == "-" or parts[1] == "-": - binary_files += 1 - else: - insertions = int(parts[0]) - deletions = int(parts[1]) - total_insertions += insertions - total_deletions += deletions - - # Get current HEAD commit for ETag computation - head_commit_result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=workspace_dir, - check=True, - capture_output=True, - text=True, - timeout=5, - ) - head_commit = head_commit_result.stdout.strip() - - # Compute ETag from checkpoint and HEAD commits - etag_value = hashlib.sha256( - f"{checkpoint.git_commit}:{head_commit}".encode() - ).hexdigest()[:16] - - response = CheckpointDiffResponse( - files_changed=files_changed, - insertions=total_insertions, - deletions=total_deletions, - diff=diff_output, - ) - - # Add cache headers with revalidation strategy - return JSONResponse( - content=response.model_dump(), - headers={ - "Cache-Control": "no-cache, must-revalidate", - "ETag": f'"{etag_value}"', - "X-Binary-Files": str(binary_files), - }, - ) - - except subprocess.CalledProcessError as e: - logger.error(f"Failed to get diff stats: {e.stderr}", exc_info=True) - # Return error response when parsing fails (not misleading zeros) - raise HTTPException(status_code=500, detail="Internal error parsing diff statistics") - except subprocess.TimeoutExpired: - logger.error(f"Git diff timed out for checkpoint {checkpoint_id}") - raise HTTPException(status_code=500, detail="Diff operation timed out") - - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to get checkpoint diff {checkpoint_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Failed to get checkpoint diff") diff --git a/codeframe/ui/routers/context.py b/codeframe/ui/routers/context.py deleted file mode 100644 index 5600f37e..00000000 --- a/codeframe/ui/routers/context.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Context management API endpoints. - -This module provides API endpoints for managing agent context items, -including listing, creating, updating, deleting, and flash save operations. -""" - -from typing import Optional -from datetime import datetime, UTC -from fastapi import APIRouter, HTTPException, Depends -from fastapi.responses import JSONResponse - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager -from codeframe.core.models import ContextItemResponse -from codeframe.lib.context_manager import ContextManager -from codeframe.lib.token_counter import TokenCounter - -# Create router with prefix and tags -router = APIRouter(tags=["context"]) - - -@router.get("/api/agents/{agent_id}/context") -async def list_context_items( - agent_id: str, - project_id: int, - tier: Optional[str] = None, - limit: int = 100, - offset: int = 0, - db: Database = Depends(get_db), current_user: User = Depends(get_current_user), -): - """List context items for an agent with optional filters (T021). - - Args: - agent_id: Agent ID to list context items for - project_id: Project ID for the context items - tier: Optional filter by tier (HOT, WARM, COLD) - limit: Maximum items to return (default: 100) - offset: Number of items to skip (default: 0) - db: Database instance (injected) - - Returns: - 200 OK: Dictionary with: - - items: List[ContextItemResponse] - - total: int (total items matching filter) - - offset: int - - limit: int - - Raises: - HTTPException: - - 422: Invalid request (validation error) - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get context items from database (returns a list, not a dict) - items_list = db.list_context_items( - project_id=project_id, agent_id=agent_id, tier=tier, limit=limit, offset=offset - ) - - # Convert items to ContextItemResponse models - items = [ - ContextItemResponse( - id=item["id"], - agent_id=item["agent_id"], - item_type=item["item_type"], - content=item["content"], - importance_score=item["importance_score"], - tier=item["current_tier"], - access_count=item["access_count"], - created_at=item["created_at"], - last_accessed=item["last_accessed"], - ) - for item in items_list - ] - - return {"items": items, "total": len(items), "offset": offset, "limit": limit} - - -@router.delete("/api/agents/{agent_id}/context/{item_id}", status_code=204) -async def delete_context_item( - agent_id: str, item_id: str, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user) -): - """Delete a context item (T022). - - Args: - agent_id: Agent ID (used for ownership validation) - item_id: Context item ID to delete (UUID string) - project_id: Project ID (used for ownership validation) - db: Database instance (injected) - - Returns: - 204 No Content: Successful deletion - - Raises: - HTTPException: - - 404: Context item not found - - 403: Context item does not belong to specified agent/project - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Check if item exists - item = db.get_context_item(item_id) - - if not item: - raise HTTPException(status_code=404, detail=f"Context item {item_id} not found") - - # Validate ownership - ensure item belongs to the specified agent and project - if item.get("agent_id") != agent_id: - raise HTTPException( - status_code=403, - detail=f"Context item {item_id} does not belong to agent {agent_id}", - ) - - if item.get("project_id") != project_id: - raise HTTPException( - status_code=403, - detail=f"Context item {item_id} does not belong to project {project_id}", - ) - - # Delete context item after ownership validation passes - db.delete_context_item(item_id) - - # Return 204 No Content (no response body) - return None - - -@router.post("/api/agents/{agent_id}/context/update-scores", response_model=dict) -async def update_context_scores(agent_id: str, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): - """Recalculate importance scores for all context items (T033). - - Triggers batch recalculation of importance scores for all context items - belonging to the specified agent on a project. Scores are recalculated based on: - - Current age (time since creation) - - Access patterns (access_count) - - Item type weights - - Use cases: - - Periodic batch updates (cron job) - - Manual trigger after time passage - - Debugging/testing score calculations - - Args: - agent_id: Agent ID to recalculate scores for - project_id: Project ID the agent is working on (query parameter) - db: Database instance (injected) - - Returns: - 200 OK: {updated_count: int} - Number of items updated - - Example: - POST /api/agents/backend-worker-001/context/update-scores?project_id=123 - Response: {"updated_count": 150} - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Create context manager - context_mgr = ContextManager(db=db) - - # Recalculate scores for all agent context items on this project - updated_count = context_mgr.recalculate_scores_for_agent(project_id, agent_id) - - return {"updated_count": updated_count} - - -@router.post("/api/agents/{agent_id}/context/update-tiers", response_model=dict) -async def update_context_tiers(agent_id: str, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): - """Recalculate scores and reassign tiers for all context items (T042). - - Triggers batch recalculation of importance scores AND tier reassignment - for all context items belonging to the specified agent on a project. This operation: - 1. Recalculates importance scores based on current age/access patterns - 2. Reassigns tiers (HOT >= 0.8, WARM 0.4-0.8, COLD < 0.4) - - Use cases: - - Periodic tier maintenance (hourly cron job) - - Manual trigger to move aged items to lower tiers - - After major time passage (e.g., daily cleanup) - - Args: - agent_id: Agent ID to update tiers for - project_id: Project ID the agent is working on (query parameter) - db: Database instance (injected) - - Returns: - 200 OK: {updated_count: int} - Number of items updated with new tiers - - Example: - POST /api/agents/backend-worker-001/context/update-tiers?project_id=123 - Response: {"updated_count": 150} - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Create context manager - context_mgr = ContextManager(db=db) - - # Recalculate scores AND reassign tiers for all agent context items on this project - updated_count = context_mgr.update_tiers_for_agent(project_id, agent_id) - - return {"updated_count": updated_count} - - -@router.post("/api/agents/{agent_id}/flash-save") -async def flash_save_context( - agent_id: str, project_id: int, force: bool = False, db: Database = Depends(get_db), current_user: User = Depends(get_current_user) -): - """Trigger flash save for an agent's context (T054). - - Creates a checkpoint with full context state and archives COLD tier items - to reduce memory footprint. Only triggers if context exceeds 80% of 180k token limit - (144k tokens) unless force=True. - - Args: - agent_id: Agent ID to flash save - project_id: Project ID the agent is working on (query parameter) - force: Force flash save even if below threshold (default: False) - db: Database instance (injected) - - Returns: - 200 OK: FlashSaveResponse with checkpoint_id, tokens_before, tokens_after, reduction_percentage - 400 Bad Request: If below threshold and force=False - - Example: - POST /api/agents/backend-worker-001/flash-save?project_id=123&force=false - Response: { - "checkpoint_id": 42, - "tokens_before": 150000, - "tokens_after": 50000, - "reduction_percentage": 66.67, - "items_archived": 20, - "hot_items_retained": 10, - "warm_items_retained": 15 - } - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Create context manager - context_mgr = ContextManager(db=db) - - # Check if flash save should be triggered - should_save = context_mgr.should_flash_save(project_id, agent_id, force=force) - - if not should_save: - return JSONResponse( - status_code=400, - content={"error": "Context below threshold. Use force=true to override."}, - ) - - # Execute flash save - result = context_mgr.flash_save(project_id, agent_id) - - # Emit WebSocket event (T059) - # Note: broadcast_json doesn't exist on manager, should be broadcast - await manager.broadcast( - { - "type": "flash_save_completed", - "agent_id": agent_id, - "project_id": project_id, - "checkpoint_id": result["checkpoint_id"], - "reduction_percentage": result["reduction_percentage"], - } - ) - - return result - - -@router.get("/api/agents/{agent_id}/flash-save/checkpoints") -async def list_flash_save_checkpoints( - agent_id: str, - project_id: int, - limit: int = 10, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """List checkpoints for an agent (T055). - - Returns metadata about flash save checkpoints, sorted by creation time (most recent first). - Does not include the full checkpoint_data JSON to keep response lightweight. - - Args: - agent_id: Agent ID to list checkpoints for - project_id: Project ID for authorization (query parameter) - limit: Maximum number of checkpoints to return (default: 10, max: 100) - db: Database instance (injected) - - Returns: - 200 OK: List of checkpoint metadata objects - - Example: - GET /api/agents/backend-worker-001/flash-save/checkpoints?project_id=123&limit=5 - Response: [ - { - "id": 42, - "agent_id": "backend-worker-001", - "items_count": 50, - "items_archived": 20, - "hot_items_retained": 15, - "token_count": 150000, - "created_at": "2025-11-14T10:30:00Z" - }, - ... - ] - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Clamp limit to reasonable range - limit = min(max(limit, 1), 100) - - # Get checkpoints from database (all checkpoints for this agent) - all_checkpoints = db.list_checkpoints(agent_id, limit=limit * 2) # Get more to account for filtering - - # Security: Filter checkpoints to only include those for this project - # The checkpoint_data JSON includes project_id, so we parse and filter - import json - - filtered_checkpoints = [] - for checkpoint in all_checkpoints: - checkpoint_data_str = checkpoint.get("checkpoint_data", "{}") - try: - checkpoint_data = json.loads(checkpoint_data_str) - # Check if checkpoint belongs to requested project - if checkpoint_data.get("project_id") == project_id: - # Remove checkpoint_data from response (too large) - checkpoint.pop("checkpoint_data", None) - filtered_checkpoints.append(checkpoint) - - # Stop when we have enough checkpoints - if len(filtered_checkpoints) >= limit: - break - except json.JSONDecodeError: - # Skip malformed checkpoints - continue - - return filtered_checkpoints - - -@router.get("/api/agents/{agent_id}/context/stats") -async def get_context_stats(agent_id: str, project_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user)): - """Get context statistics for an agent (T067). - - Returns tier counts and token usage breakdown for an agent's context. - - Args: - agent_id: Agent ID to get stats for - project_id: Project ID the agent is working on - db: Database instance (injected) - - Returns: - 200 OK: ContextStats object with tier counts and token usage - - Example: - GET /api/agents/backend-worker-001/context/stats?project_id=123 - Response: { - "agent_id": "backend-worker-001", - "project_id": 123, - "hot_count": 20, - "warm_count": 50, - "cold_count": 30, - "total_count": 100, - "hot_tokens": 15000, - "warm_tokens": 25000, - "cold_tokens": 10000, - "total_tokens": 50000, - "token_usage_percentage": 27.8, - "calculated_at": "2025-11-14T10:30:00Z" - } - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get all context items for this agent - hot_items = db.list_context_items( - project_id=project_id, agent_id=agent_id, tier="hot", limit=10000 - ) - - warm_items = db.list_context_items( - project_id=project_id, agent_id=agent_id, tier="warm", limit=10000 - ) - - cold_items = db.list_context_items( - project_id=project_id, agent_id=agent_id, tier="cold", limit=10000 - ) - - # Calculate token counts per tier - token_counter = TokenCounter(cache_enabled=True) - - hot_tokens = token_counter.count_context_tokens(hot_items) - warm_tokens = token_counter.count_context_tokens(warm_items) - cold_tokens = token_counter.count_context_tokens(cold_items) - - total_tokens = hot_tokens + warm_tokens + cold_tokens - - # Calculate token usage percentage (out of 180k limit) - TOKEN_LIMIT = 180000 - token_usage_percentage = (total_tokens / TOKEN_LIMIT) * 100 if TOKEN_LIMIT > 0 else 0.0 - - return { - "agent_id": agent_id, - "project_id": project_id, - "hot_count": len(hot_items), - "warm_count": len(warm_items), - "cold_count": len(cold_items), - "total_count": len(hot_items) + len(warm_items) + len(cold_items), - "hot_tokens": hot_tokens, - "warm_tokens": warm_tokens, - "cold_tokens": cold_tokens, - "total_tokens": total_tokens, - "token_usage_percentage": round(token_usage_percentage, 2), - "calculated_at": datetime.now(UTC).isoformat(), - } - - -@router.get("/api/agents/{agent_id}/context/items") -async def get_context_items( - agent_id: str, - project_id: int, - tier: Optional[str] = None, - limit: int = 100, - db: Database = Depends(get_db), current_user: User = Depends(get_current_user), -): - """Get context items for an agent, optionally filtered by tier. - - Returns a list of context items with their content and metadata. - - Args: - agent_id: Agent ID to get items for - project_id: Project ID the agent is working on - tier: Optional tier filter ('hot', 'warm', 'cold') - limit: Maximum number of items to return (default: 100, max: 1000) - db: Database instance (injected) - - Returns: - 200 OK: List of ContextItem objects - - Example: - GET /api/agents/backend-worker-001/context/items?project_id=123&tier=hot&limit=20 - """ - # Clamp limit to reasonable range - limit = min(max(limit, 1), 1000) - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Normalize and validate tier if provided - if tier: - tier = tier.lower() - if tier not in ["hot", "warm", "cold"]: - raise HTTPException( - status_code=400, detail="Invalid tier. Must be 'hot', 'warm', or 'cold'" - ) - - # Get items from database - items = db.list_context_items(project_id=project_id, agent_id=agent_id, tier=tier, limit=limit) - - return items diff --git a/codeframe/ui/routers/discovery.py b/codeframe/ui/routers/discovery.py deleted file mode 100644 index 323c5e1f..00000000 --- a/codeframe/ui/routers/discovery.py +++ /dev/null @@ -1,814 +0,0 @@ -"""Discovery workflow router. - -This module handles discovery-related endpoints for projects, -allowing submission of discovery answers and retrieval of discovery progress. -""" - -import asyncio -import os -import logging -from typing import Dict, Any - -from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks - -from codeframe.persistence.database import Database -from codeframe.core.models import DiscoveryAnswer, DiscoveryAnswerResponse -from codeframe.agents.lead_agent import LeadAgent -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager -from codeframe.ui.websocket_broadcasts import ( - broadcast_planning_started, - broadcast_issues_generated, - broadcast_tasks_decomposed, - broadcast_tasks_ready, - broadcast_planning_failed, -) - -# Module logger -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/projects/{project_id}/discovery", tags=["discovery"]) - - -async def generate_prd_background(project_id: int, db: Database, api_key: str): - """Background task to generate PRD after discovery completes. - - Broadcasts progress at each stage: - 1. prd_generation_started - Initial notification - 2. prd_generation_progress (gathering_data) - Collecting discovery answers - 3. prd_generation_progress (calling_llm) - Sending to Claude API - 4. prd_generation_progress (saving) - Saving PRD to database/file - 5. prd_generation_completed - Final notification - 6. Spawns planning automation as a separate async task - - Args: - project_id: Project ID - db: Database instance - api_key: API key for Claude - """ - async def broadcast_progress(stage: str, message: str, progress_pct: int = 0): - """Helper to broadcast progress updates.""" - await manager.broadcast( - { - "type": "prd_generation_progress", - "project_id": project_id, - "stage": stage, - "message": message, - "progress_pct": progress_pct, - }, - project_id=project_id, - ) - - try: - logger.info(f"Starting PRD generation for project {project_id}") - - # Stage 1: Starting - await manager.broadcast( - { - "type": "prd_generation_started", - "project_id": project_id, - "status": "generating", - }, - project_id=project_id, - ) - - # Stage 2: Gathering discovery data - await broadcast_progress( - "gathering_data", - "Gathering discovery answers...", - 10 - ) - agent = LeadAgent(project_id=project_id, db=db, api_key=api_key) - - # Stage 3: Building prompt and calling LLM - await broadcast_progress( - "calling_llm", - "Generating PRD with AI...", - 30 - ) - # Timeout after 120 seconds to prevent indefinite hangs - PRD_GENERATION_TIMEOUT = 120 # seconds - try: - prd_content = await asyncio.wait_for( - asyncio.to_thread(agent.generate_prd), - timeout=PRD_GENERATION_TIMEOUT - ) - except asyncio.TimeoutError: - logger.error(f"PRD generation timed out after {PRD_GENERATION_TIMEOUT}s for project {project_id}") - await manager.broadcast( - { - "type": "prd_generation_failed", - "project_id": project_id, - "status": "failed", - "error": f"PRD generation timed out after {PRD_GENERATION_TIMEOUT} seconds. Please try again.", - }, - project_id=project_id, - ) - return # Exit background task - - logger.info(f"PRD generated successfully for project {project_id}") - - # Stage 4: Saving PRD - await broadcast_progress( - "saving", - "Saving PRD document...", - 80 - ) - - # Update project phase to planning - await asyncio.to_thread( - db.update_project, project_id, {"phase": "planning"} - ) - - # Stage 5: Complete - await manager.broadcast( - { - "type": "prd_generation_completed", - "project_id": project_id, - "status": "completed", - "progress_pct": 100, - "prd_preview": prd_content[:200] if prd_content else "", - }, - project_id=project_id, - ) - - # Trigger planning automation as a non-blocking background task - # This allows PRD completion to be reported immediately while planning runs - def _handle_planning_exception(task: asyncio.Task) -> None: - """Log any unhandled exceptions from background planning task.""" - if not task.cancelled() and task.exception(): - logger.error( - f"Unhandled exception in planning background task: {task.exception()}", - exc_info=task.exception() - ) - - planning_task = asyncio.create_task(generate_planning_background(project_id, db, api_key)) - planning_task.add_done_callback(_handle_planning_exception) - - except Exception as e: - logger.error(f"Failed to generate PRD for project {project_id}: {e}", exc_info=True) - # Broadcast error - await manager.broadcast( - { - "type": "prd_generation_failed", - "project_id": project_id, - "status": "failed", - "error": str(e), - }, - project_id=project_id, - ) - - -async def generate_planning_background(project_id: int, db: Database, api_key: str): - """Background task to generate issues and tasks after PRD completion. - - This function implements planning automation: - 1. planning_started - Notify automation begins - 2. generate_issues - Create issues from PRD - 3. issues_generated - Report issues created - 4. decompose_prd - Decompose issues into tasks - 5. tasks_decomposed - Report tasks created - 6. tasks_ready - Signal ready for user review - - Note: LeadAgent methods are synchronous and use the sync Anthropic client, - so asyncio.to_thread() is appropriate for running them without blocking. - - Args: - project_id: Project ID - db: Database instance - api_key: API key for Claude - """ - # Configurable timeout for AI operations (default 2 minutes per operation) - planning_timeout = float(os.environ.get("PLANNING_OPERATION_TIMEOUT", "120")) - - try: - logger.info(f"Starting planning automation for project {project_id}") - - # Stage 1: Broadcast planning started - await broadcast_planning_started(manager, project_id) - - # Initialize LeadAgent for issue/task generation - agent = LeadAgent(project_id=project_id, db=db, api_key=api_key) - - # Stage 2: Generate issues from PRD (with timeout) - logger.info(f"Generating issues for project {project_id}") - issues = await asyncio.wait_for( - asyncio.to_thread(agent.generate_issues, sprint_number=1), - timeout=planning_timeout - ) - issue_count = len(issues) if issues else 0 - - # Stage 3: Broadcast issues generated - await broadcast_issues_generated(manager, project_id, issue_count) - logger.info(f"Generated {issue_count} issues for project {project_id}") - - # Stage 4: Decompose PRD into tasks (with timeout) - logger.info(f"Decomposing PRD into tasks for project {project_id}") - decomposition_result = await asyncio.wait_for( - asyncio.to_thread(agent.decompose_prd), - timeout=planning_timeout - ) - task_count = decomposition_result.get("tasks", 0) if decomposition_result else 0 - - # Stage 5: Broadcast tasks decomposed - await broadcast_tasks_decomposed(manager, project_id, task_count) - logger.info(f"Decomposed into {task_count} tasks for project {project_id}") - - # Stage 6: Broadcast tasks ready for review - await broadcast_tasks_ready(manager, project_id, task_count) - logger.info(f"Planning automation completed for project {project_id}") - - except asyncio.TimeoutError: - logger.error( - f"Planning automation timed out for project {project_id} " - f"(timeout={planning_timeout}s)", - exc_info=True - ) - await broadcast_planning_failed(manager, project_id, "Planning operation timed out") - - except Exception as e: - logger.error(f"Planning automation failed for project {project_id}: {e}", exc_info=True) - await broadcast_planning_failed(manager, project_id, str(e)) - - -@router.post("/answer") -async def submit_discovery_answer( - project_id: int, - answer_data: DiscoveryAnswer, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> DiscoveryAnswerResponse: - """Submit answer to current discovery question (Feature: 012-discovery-answer-ui, US5). - - Implementation following TDD approach (T041-T044): - - Validates project exists and is in discovery phase - - Processes answer through Lead Agent - - Broadcasts WebSocket events for real-time UI updates - - Returns updated discovery status - - Args: - project_id: Project ID - answer_data: Answer submission data (Pydantic model with validation) - db: Database instance (injected) - - Returns: - DiscoveryAnswerResponse with next question and progress - - Raises: - HTTPException: - - 400: Validation error or wrong phase - - 404: Project not found - - 500: Missing API key or processing error - """ - from codeframe.ui.websocket_broadcasts import ( - broadcast_discovery_answer_submitted, - broadcast_discovery_question_presented, - broadcast_discovery_completed, - ) - - # T041: Validate project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # T041: Validate project is in discovery phase - if project.get("phase") != "discovery": - raise HTTPException( - status_code=400, - detail=f"Project is not in discovery phase. Current phase: {project.get('phase')}", - ) - - # T042: Validate ANTHROPIC_API_KEY is available - api_key = os.environ.get("ANTHROPIC_API_KEY") - if not api_key: - raise HTTPException( - status_code=500, - detail="ANTHROPIC_API_KEY environment variable is not set. Cannot process discovery answers.", - ) - - # T042: Get Lead Agent and process answer - try: - agent = LeadAgent(project_id=project_id, db=db, api_key=api_key) - - # CRITICAL: Validate discovery is active before processing answer - status = agent.get_discovery_status() - if status.get("state") != "discovering": - raise HTTPException( - status_code=400, - detail=f"Discovery is not active. Current state: {status.get('state')}. " - f"Please start discovery first by calling POST /api/projects/{project_id}/discovery/start", - ) - - # Process the answer (trimmed by Pydantic validator) - agent.process_discovery_answer(answer_data.answer) - - # Get updated discovery status after processing - status = agent.get_discovery_status() - - except HTTPException: - # Re-raise HTTPExceptions as-is - raise - except Exception as e: - logger.error(f"Failed to process discovery answer for project {project_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to process answer: {str(e)}") - - # T043: Compute derived values from status to match LeadAgent.get_discovery_status() format - is_complete = status.get("state") == "completed" - total_questions = status.get("total_required", 0) - answered_count = status.get("answered_count", 0) - current_question_index = answered_count # Index is based on how many answered - current_question_id = status.get("current_question", {}).get("id", "") - current_question_text = status.get("current_question", {}).get("text", "") - progress_percentage = status.get("progress_percentage", 0.0) - - # T043: Broadcast WebSocket events - try: - # Broadcast answer submitted event - await broadcast_discovery_answer_submitted( - manager=manager, - project_id=project_id, - question_id=current_question_id, - answer_preview=answer_data.answer[:100], # First 100 chars - current_index=current_question_index, - total_questions=total_questions, - ) - - # Broadcast appropriate follow-up event - if is_complete: - await broadcast_discovery_completed( - manager=manager, - project_id=project_id, - total_answers=answered_count, - next_phase="prd_generation", - ) - # Trigger PRD generation in background - background_tasks.add_task( - generate_prd_background, project_id, db, api_key - ) - else: - await broadcast_discovery_question_presented( - manager=manager, - project_id=project_id, - question_id=current_question_id, - question_text=current_question_text, - current_index=current_question_index, - total_questions=total_questions, - ) - - except Exception as e: - logger.warning(f"Failed to broadcast WebSocket events for project {project_id}: {e}") - # Non-fatal - continue with response - - # T044: Generate and return response - return DiscoveryAnswerResponse( - success=True, - next_question=current_question_text if not is_complete else None, - is_complete=is_complete, - current_index=current_question_index, - total_questions=total_questions, - progress_percentage=progress_percentage, - ) - - -@router.get("/progress") -async def get_discovery_progress( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> Dict[str, Any]: - """Get discovery progress for a project (cf-17.2). - - Returns discovery progress combined with project phase. - - Response format: - { - "project_id": int, - "phase": str, # Project phase (discovery, planning, development, etc.) - "discovery": { # null if discovery not started (idle state) - "state": str, # idle, discovering, completed - "progress_percentage": float, # 0-100 - "answered_count": int, - "total_required": int, - "remaining_count": int, # Only in discovering state - "current_question": dict, # Only in discovering state - "structured_data": dict # Only in completed state - } - } - - Args: - project_id: Project ID - db: Database instance (injected) - - Returns: - Discovery progress response - - Raises: - HTTPException: - - 404: Project not found - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get project phase (default to "discovery" if not set) - project_phase = project.get("phase", "discovery") - - # Initialize LeadAgent to get discovery status - # Use dummy API key for status retrieval (no API calls made) - try: - agent = LeadAgent(project_id=project_id, db=db, api_key="dummy-key-for-status") - - # Get discovery status - status = agent.get_discovery_status() - - # If discovery is in idle state, return null for discovery field - if status["state"] == "idle": - discovery_data = None - else: - # Build discovery response, excluding sensitive fields - # Use .get() with defaults to handle edge cases where keys may not exist - discovery_data = { - "state": status["state"], - "progress_percentage": status.get("progress_percentage", 0.0), - "answered_count": status.get("answered_count", 0), - "total_required": status.get("total_required", 0), - } - - # Add state-specific fields - if status["state"] == "discovering": - discovery_data["remaining_count"] = status.get("remaining_count", 0) - # Map backend "text" field to frontend "question" field - raw_question = status.get("current_question") - if raw_question: - discovery_data["current_question"] = { - "id": raw_question.get("id", ""), - "question": raw_question.get("text", ""), # Map text -> question - "category": raw_question.get("category", ""), - } - else: - discovery_data["current_question"] = None - - if status["state"] == "completed": - discovery_data["structured_data"] = status.get("structured_data") - - # Exclude "answers" field for security (contains raw user input) - - return {"project_id": project_id, "phase": project_phase, "discovery": discovery_data} - - except Exception as e: - # Log error but don't expose internals - raise HTTPException( - status_code=500, detail=f"Error retrieving discovery progress: {str(e)}" - ) - - -@router.post("/restart") -async def restart_discovery( - project_id: int, - confirmed: bool = False, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> Dict[str, Any]: - """Restart discovery from any phase (Issue #247). - - This endpoint supports full discovery restart from any project phase. - When restarting from a phase other than "discovery", it requires confirmation - because it will delete PRD, tasks, and issues. - - For projects in "discovery" phase, no confirmation is needed and only the - discovery state is reset (preserving answers for potential continuation). - - For projects in other phases (planning, active, review), confirmation is - required and the following data will be deleted: - - Discovery answers - - PRD content - - Generated tasks and issues - - Project phase will be reset to "discovery" - - Args: - project_id: Project ID - confirmed: If True, proceed with full reset. If False, return confirmation - request for non-discovery phases. - db: Database instance (injected) - current_user: Authenticated user (injected) - - Returns: - Success message with new state, or confirmation request - - Raises: - HTTPException: - - 403: User lacks project access - - 404: Project not found - - 500: Server error during reset - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - current_phase = project.get("phase", "discovery") - - # For discovery phase, use simple reset (no confirmation needed) - if current_phase in ("discovery", None): - try: - agent = LeadAgent(project_id=project_id, db=db, api_key="dummy-key-for-reset") - agent.reset_discovery() - - await manager.broadcast( - { - "type": "discovery_reset", - "project_id": project_id, - "message": "Discovery has been reset. Click 'Start Discovery' to begin again.", - }, - project_id=project_id, - ) - - logger.info(f"Discovery reset for project {project_id} by user {current_user.id}") - - return { - "success": True, - "message": "Discovery has been reset. You can now start discovery again.", - "state": "idle", - } - except Exception as e: - logger.error(f"Failed to reset discovery for project {project_id}: {e}") - raise HTTPException( - status_code=500, detail=f"Failed to reset discovery: {str(e)}" - ) - - # For other phases, check what data exists that would be deleted - prd = db.get_prd(project_id) - prd_exists = prd is not None - - # Count discovery answers - answers = db.get_memories_by_category(project_id, "discovery_answers") - answer_count = len(answers) - - # Count tasks and issues - tasks = db.get_project_tasks(project_id) - task_count = len(tasks) - issues = db.get_project_issues(project_id) - issue_count = len(issues) - - # If not confirmed, return confirmation request - if not confirmed: - return { - "requires_confirmation": True, - "message": ( - "This will permanently delete all discovery answers, the PRD, " - "and any generated tasks. You'll start from scratch. " - "Are you sure?" - ), - "data_to_be_deleted": { - "prd_exists": prd_exists, - "answer_count": answer_count, - "task_count": task_count, - "issue_count": issue_count, - }, - } - - # Confirmed - perform full reset - try: - # Initialize LeadAgent and perform full reset - agent = LeadAgent(project_id=project_id, db=db, api_key="dummy-key-for-reset") - reset_result = agent.full_reset_discovery() - - # Delete PRD - prd_deleted = db.delete_prd(project_id) - - # Delete tasks and issues - deleted_items = db.delete_project_tasks_and_issues(project_id) - - # Reset project phase to discovery - db.update_project(project_id, {"phase": "discovery"}) - - # Broadcast discovery reset event with detailed information - await manager.broadcast( - { - "type": "discovery_reset", - "project_id": project_id, - "phase": "discovery", - "message": ( - "Discovery has been fully reset. All answers, PRD, and tasks " - "have been cleared. Click 'Start Discovery' to begin again." - ), - "cleared_items": { - "answers": reset_result["answers"], - "prd_existed": prd_deleted, - "tasks": deleted_items["tasks"], - "issues": deleted_items["issues"], - }, - }, - project_id=project_id, - ) - - logger.info( - f"Full discovery reset for project {project_id} by user {current_user.id}: " - f"phase {current_phase} -> discovery, " - f"cleared {reset_result['answers']} answers, " - f"prd={'yes' if prd_deleted else 'no'}, " - f"{deleted_items['tasks']} tasks, {deleted_items['issues']} issues" - ) - - return { - "success": True, - "message": ( - "Discovery has been fully reset. All answers, PRD, and tasks " - "have been cleared. You can now start discovery again." - ), - "state": "idle", - "cleared_items": { - "answers": reset_result["answers"], - "prd_existed": prd_deleted, - "tasks": deleted_items["tasks"], - "issues": deleted_items["issues"], - }, - } - - except Exception as e: - logger.error(f"Failed to fully reset discovery for project {project_id}: {e}") - raise HTTPException( - status_code=500, detail=f"Failed to restart discovery: {str(e)}" - ) - - -@router.post("/generate-prd") -async def retry_prd_generation( - project_id: int, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> Dict[str, Any]: - """Retry PRD generation for a project with completed discovery. - - This endpoint allows retrying PRD generation if it previously failed, - or manually triggering it if it wasn't started automatically. - - Args: - project_id: Project ID - background_tasks: FastAPI background tasks - db: Database instance (injected) - current_user: Authenticated user (injected) - - Returns: - Success message indicating PRD generation has started - - Raises: - HTTPException: - - 400: Discovery not completed, or PRD already exists - - 403: User lacks project access - - 404: Project not found - - 500: API key not configured - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Verify API key is available - api_key = os.environ.get("ANTHROPIC_API_KEY") - if not api_key: - raise HTTPException( - status_code=500, - detail="ANTHROPIC_API_KEY environment variable is not set.", - ) - - # Check discovery state - must be completed - try: - agent = LeadAgent(project_id=project_id, db=db, api_key=api_key) - status = agent.get_discovery_status() - - if status["state"] != "completed": - raise HTTPException( - status_code=400, - detail=f"Discovery must be completed before generating PRD. " - f"Current state: {status['state']}", - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to check discovery status for project {project_id}: {e}") - raise HTTPException( - status_code=500, detail=f"Failed to check discovery status: {str(e)}" - ) - - # Start PRD generation in background - background_tasks.add_task(generate_prd_background, project_id, db, api_key) - - logger.info(f"PRD generation started for project {project_id} by user {current_user.id}") - - return { - "success": True, - "message": "PRD generation has been started. Watch for WebSocket updates.", - } - - -@router.post("/generate-tasks") -async def generate_tasks( - project_id: int, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> Dict[str, Any]: - """Manually trigger task generation from PRD. - - This endpoint allows users to manually start task generation when the - project is in the planning phase with a completed PRD. It reuses the - existing generate_planning_background() function that handles issue - creation and task decomposition. - - Args: - project_id: Project ID - background_tasks: FastAPI background tasks - db: Database instance (injected) - current_user: Authenticated user (injected) - - Returns: - Success message indicating task generation has started - - Raises: - HTTPException: - - 400: Not in planning phase, PRD missing, or tasks already exist - - 403: User lacks project access - - 404: Project not found - - 500: API key not configured - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Verify project is in planning phase - current_phase = project.get("phase", "discovery") - if current_phase != "planning": - raise HTTPException( - status_code=400, - detail=f"Project must be in planning phase to generate tasks. " - f"Current phase: {current_phase}", - ) - - # Verify PRD exists - prd = db.get_prd(project_id) - if not prd: - raise HTTPException( - status_code=400, - detail="PRD must be generated before task generation. " - "Please complete PRD generation first.", - ) - - # Check if tasks already exist - return idempotent success instead of error - # This improves UX for users who join late and miss WebSocket events - existing_tasks = db.get_project_tasks(project_id) - if existing_tasks: - logger.info( - f"Tasks already exist for project {project_id} " - f"(count: {len(existing_tasks)}). Returning idempotent success." - ) - return { - "success": True, - "message": "Tasks have already been generated for this project.", - "tasks_already_exist": True, - } - - # Verify API key is available - api_key = os.environ.get("ANTHROPIC_API_KEY") - if not api_key: - raise HTTPException( - status_code=500, - detail="ANTHROPIC_API_KEY environment variable is not set.", - ) - - # Start task generation in background - background_tasks.add_task(generate_planning_background, project_id, db, api_key) - - logger.info(f"Task generation started for project {project_id} by user {current_user.id}") - - return { - "success": True, - "message": "Task generation has been started. Watch for WebSocket updates.", - } diff --git a/codeframe/ui/routers/git.py b/codeframe/ui/routers/git.py deleted file mode 100644 index d162f5cb..00000000 --- a/codeframe/ui/routers/git.py +++ /dev/null @@ -1,763 +0,0 @@ -"""Git operations router for CodeFRAME (#270). - -This module provides REST API endpoints for git operations: -- Branch creation and management -- Commit creation and listing -- Git status retrieval - -Endpoints follow the pattern: /api/projects/{project_id}/git/* -""" - -import logging -import os -import re -from datetime import datetime, UTC -from pathlib import Path -from typing import List, Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field, field_validator -import git - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.git.workflow_manager import GitWorkflowManager -from codeframe.ui.shared import manager -from codeframe.ui.websocket_broadcasts import ( - broadcast_branch_created, - broadcast_commit_created, -) - -# Git branch name validation pattern -# Allows: alphanumeric, hyphens, underscores, forward slashes, dots (not leading/trailing) -# Disallows: spaces, ~, ^, :, ?, *, [, \, .., @{, consecutive dots -BRANCH_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9][-a-zA-Z0-9_./]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$') - - -def validate_branch_name(branch_name: str) -> str: - """Validate git branch name for safety. - - Args: - branch_name: Branch name to validate - - Returns: - Validated branch name - - Raises: - ValueError: If branch name contains invalid characters - """ - if not branch_name: - raise ValueError("Branch name cannot be empty") - - # Check for dangerous patterns - dangerous_patterns = ['..', '@{', '~', '^', ':', '?', '*', '[', '\\', ' '] - for pattern in dangerous_patterns: - if pattern in branch_name: - raise ValueError(f"Branch name contains invalid character sequence: {pattern}") - - # Check against allowed pattern - if not BRANCH_NAME_PATTERN.match(branch_name): - raise ValueError( - "Branch name must start and end with alphanumeric characters " - "and contain only letters, numbers, hyphens, underscores, forward slashes, or dots" - ) - - return branch_name - - -def validate_file_paths(file_paths: List[str], repo_root: str) -> List[str]: - """Validate file paths to prevent directory traversal attacks. - - Args: - file_paths: List of file paths to validate - repo_root: Repository root directory (working tree) - - Returns: - List of validated, resolved file paths - - Raises: - ValueError: If any path is invalid or attempts to escape the workspace - """ - validated_paths = [] - repo_root_resolved = os.path.realpath(repo_root) - - for path in file_paths: - # Reject absolute paths - if os.path.isabs(path): - raise ValueError(f"Absolute paths not allowed: {path}") - - # Reject paths with '..' segments (directory traversal) - if '..' in path.split(os.sep) or '..' in path.split('/'): - raise ValueError(f"Path traversal not allowed: {path}") - - # Resolve the path against repo root - candidate = os.path.join(repo_root_resolved, path) - resolved = os.path.realpath(candidate) - - # Ensure resolved path is within repo root - try: - common = os.path.commonpath([repo_root_resolved, resolved]) - if common != repo_root_resolved: - raise ValueError(f"Path escapes workspace: {path}") - except ValueError: - # commonpath raises ValueError for paths on different drives (Windows) - raise ValueError(f"Path escapes workspace: {path}") - - validated_paths.append(path) - - return validated_paths - - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/projects", tags=["git"]) - - -# ============================================================================ -# Request/Response Models -# ============================================================================ - - -class BranchCreateRequest(BaseModel): - """Request model for branch creation.""" - - issue_number: str = Field(..., min_length=1, description="Issue number (e.g., '1.1')") - issue_title: str = Field(..., min_length=1, description="Issue title for branch name") - - @field_validator("issue_number", "issue_title") - @classmethod - def strip_whitespace(cls, v: str) -> str: - """Strip whitespace and validate non-empty.""" - stripped = v.strip() - if not stripped: - raise ValueError("Field cannot be empty or whitespace only") - return stripped - - -class BranchResponse(BaseModel): - """Response model for branch details.""" - - id: int - branch_name: str - issue_id: int - status: str - created_at: str - merged_at: Optional[str] = None - merge_commit: Optional[str] = None - - -class BranchCreateResponse(BaseModel): - """Response model for branch creation.""" - - branch_name: str - issue_number: str - status: str - created_at: str - - -class BranchListResponse(BaseModel): - """Response model for branch listing.""" - - branches: List[BranchResponse] - - -class CommitRequest(BaseModel): - """Request model for commit creation.""" - - task_id: int = Field(..., description="Task ID this commit is for") - files_modified: List[str] = Field(..., min_length=1, description="List of modified files") - agent_id: str = Field(..., min_length=1, description="Agent ID making the commit") - - @field_validator("files_modified") - @classmethod - def validate_files(cls, v: List[str]) -> List[str]: - """Validate files list is not empty.""" - if not v: - raise ValueError("files_modified cannot be empty") - return v - - -class CommitResponse(BaseModel): - """Response model for commit creation.""" - - commit_hash: str - commit_message: str - files_changed: int - timestamp: str - - -class CommitListItem(BaseModel): - """Individual commit in list response.""" - - hash: str - short_hash: str - message: str - author: str - timestamp: str - files_changed: Optional[int] = None - - -class CommitListResponse(BaseModel): - """Response model for commit listing.""" - - commits: List[CommitListItem] - - -class GitStatusResponse(BaseModel): - """Response model for git status.""" - - current_branch: str - is_dirty: bool - modified_files: List[str] - untracked_files: List[str] - staged_files: List[str] - - -# ============================================================================ -# Helper Functions -# ============================================================================ - - -def get_project_or_404(db: Database, project_id: int) -> dict: - """Get project or raise 404. - - Args: - db: Database instance - project_id: Project ID - - Returns: - Project dictionary - - Raises: - HTTPException: 404 if project not found - """ - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - return project - - -def check_project_access(db: Database, user: User, project_id: int) -> None: - """Check user has project access or raise 403. - - Args: - db: Database instance - user: Current user - project_id: Project ID - - Raises: - HTTPException: 403 if user lacks access - """ - if not db.user_has_project_access(user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - -def get_git_workflow_manager(project: dict, db: Database) -> GitWorkflowManager: - """Get GitWorkflowManager for a project. - - Args: - project: Project dictionary - db: Database instance - - Returns: - GitWorkflowManager instance - - Raises: - HTTPException: 400 if project has no workspace - HTTPException: 500 if not a git repository - """ - workspace_path = project.get("workspace_path") - if not workspace_path: - raise HTTPException(status_code=400, detail="Project has no workspace") - - try: - return GitWorkflowManager(Path(workspace_path), db) - except git.InvalidGitRepositoryError: - raise HTTPException( - status_code=500, detail="Project workspace is not a git repository" - ) - except git.NoSuchPathError: - raise HTTPException(status_code=500, detail="Project workspace path does not exist") - - -# ============================================================================ -# Branch Endpoints -# ============================================================================ - - -@router.post("/{project_id}/git/branches", status_code=201, response_model=BranchCreateResponse) -async def create_branch( - project_id: int, - request: BranchCreateRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Create a feature branch for an issue. - - Args: - project_id: Project ID - request: Branch creation request - db: Database instance - current_user: Authenticated user - - Returns: - Created branch details - - Raises: - HTTPException: - - 400: Invalid parameters - - 403: Access denied - - 404: Project or issue not found - - 409: Branch already exists (including concurrent creation) - - 500: Git operation failed - """ - # Get project and check access - project = get_project_or_404(db, project_id) - check_project_access(db, current_user, project_id) - - # Find the issue - must exist before creating branch - issues = db.get_project_issues(project_id) - matching_issue = next( - (i for i in issues if i.issue_number == request.issue_number), - None - ) - if not matching_issue: - raise HTTPException( - status_code=404, - detail=f"Issue '{request.issue_number}' not found in project" - ) - issue_id = matching_issue.id - - # Get workflow manager - workflow_manager = get_git_workflow_manager(project, db) - - try: - # Create the branch - branch_name = workflow_manager.create_feature_branch( - request.issue_number, - request.issue_title - ) - - # Broadcast event - await broadcast_branch_created( - manager, - project_id, - branch_name, - request.issue_number, - issue_id, - ) - - logger.info(f"Created branch {branch_name} for project {project_id}") - - return BranchCreateResponse( - branch_name=branch_name, - issue_number=request.issue_number, - status="active", - created_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"), - ) - - except ValueError as e: - # Branch already exists (pre-check) or validation error - error_msg = str(e).lower() - if "already exists" in error_msg: - raise HTTPException(status_code=409, detail=str(e)) - raise HTTPException(status_code=400, detail=str(e)) - except git.GitCommandError as e: - # Handle race condition: branch created between check and create_head - error_msg = str(e).lower() - if "already exists" in error_msg or "cannot lock ref" in error_msg: - logger.warning(f"Branch creation race condition detected: {e}") - raise HTTPException( - status_code=409, - detail="Branch already exists (concurrent creation detected)" - ) - logger.error(f"Git error creating branch: {e}") - raise HTTPException(status_code=500, detail=f"Git operation failed: {e}") - - -# Valid branch status values -VALID_BRANCH_STATUSES = {"active", "merged", "abandoned"} - - -@router.get("/{project_id}/git/branches", response_model=BranchListResponse) -async def list_branches( - project_id: int, - status: Optional[str] = Query(default="active", description="Filter by status (active, merged, abandoned)"), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """List branches for a project. - - Args: - project_id: Project ID - status: Optional status filter (active, merged, abandoned) - db: Database instance - current_user: Authenticated user - - Returns: - List of branches - - Raises: - HTTPException: - - 400: Invalid status value - - 403: Access denied - - 404: Project not found - """ - # Get project and check access - get_project_or_404(db, project_id) - check_project_access(db, current_user, project_id) - - # Validate status parameter - if status is not None and status not in VALID_BRANCH_STATUSES: - raise HTTPException( - status_code=400, - detail=f"Invalid status '{status}'. Must be one of: {', '.join(VALID_BRANCH_STATUSES)}" - ) - - # Get project issues to filter branches - project_issues = db.get_project_issues(project_id) - project_issue_ids = {i.id for i in project_issues} - - # Get branches by status (use explicit None check) - all_branches = db.get_branches_by_status(status) if status is not None else [] - - # Filter to only branches for this project's issues - project_branches = [ - b for b in all_branches - if b.get("issue_id") in project_issue_ids - ] - - # Convert to response format - branches = [ - BranchResponse( - id=b["id"], - branch_name=b["branch_name"], - issue_id=b["issue_id"], - status=b["status"], - created_at=b.get("created_at", ""), - merged_at=b.get("merged_at"), - merge_commit=b.get("merge_commit"), - ) - for b in project_branches - ] - - return BranchListResponse(branches=branches) - - -@router.get("/{project_id}/git/branches/{branch_name:path}", response_model=BranchResponse) -async def get_branch( - project_id: int, - branch_name: str, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get branch details. - - Args: - project_id: Project ID - branch_name: Branch name - db: Database instance - current_user: Authenticated user - - Returns: - Branch details - - Raises: - HTTPException: - - 403: Access denied - - 404: Project or branch not found - """ - # Get project and check access - get_project_or_404(db, project_id) - check_project_access(db, current_user, project_id) - - # Validate branch name - try: - validate_branch_name(branch_name) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Get project issues to verify branch belongs to project - project_issues = db.get_project_issues(project_id) - project_issue_ids = [i.id for i in project_issues] - - # Single-query lookup for performance - branch = db.get_branch_by_name_and_issues(branch_name, project_issue_ids) - if branch: - return BranchResponse( - id=branch["id"], - branch_name=branch["branch_name"], - issue_id=branch["issue_id"], - status=branch["status"], - created_at=branch.get("created_at", ""), - merged_at=branch.get("merged_at"), - merge_commit=branch.get("merge_commit"), - ) - - raise HTTPException(status_code=404, detail=f"Branch '{branch_name}' not found") - - -# ============================================================================ -# Commit Endpoints -# ============================================================================ - - -@router.post("/{project_id}/git/commit", status_code=201, response_model=CommitResponse) -async def create_commit( - project_id: int, - request: CommitRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Create a git commit for task changes. - - Args: - project_id: Project ID - request: Commit request - db: Database instance - current_user: Authenticated user - - Returns: - Commit details - - Raises: - HTTPException: - - 400: No files to commit or validation error - - 403: Access denied - - 404: Project or task not found - - 500: Git operation failed - """ - # Get project and check access - project = get_project_or_404(db, project_id) - check_project_access(db, current_user, project_id) - - # Validate files_modified is not empty - if not request.files_modified: - raise HTTPException(status_code=400, detail="No files to commit") - - # Get task and verify it belongs to this project - task = db.get_task(request.task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {request.task_id} not found") - if task.project_id != project_id: - raise HTTPException(status_code=404, detail=f"Task {request.task_id} not found in project") - - # Get workflow manager - workflow_manager = get_git_workflow_manager(project, db) - - # Validate file paths to prevent directory traversal attacks - try: - validate_file_paths(request.files_modified, workflow_manager.repo.working_tree_dir) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - try: - # Create task dict for commit_task_changes - task_dict = { - "id": task.id, - "project_id": task.project_id, - "task_number": task.task_number, - "title": task.title, - "description": task.description, - } - - # Create commit - commit_hash = workflow_manager.commit_task_changes( - task_dict, - request.files_modified, - request.agent_id - ) - - # Get commit message from the specific commit (not HEAD, which may have moved) - commit = workflow_manager.repo.commit(commit_hash) - commit_message = commit.message.strip() - - # Broadcast event - await broadcast_commit_created( - manager, - project_id, - task.id, - commit_hash, - commit_message, - len(request.files_modified), - ) - - logger.info(f"Created commit {commit_hash[:7]} for task {task.task_number}") - - return CommitResponse( - commit_hash=commit_hash, - commit_message=commit_message, - files_changed=len(request.files_modified), - timestamp=datetime.now(UTC).isoformat().replace("+00:00", "Z"), - ) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except KeyError as e: - raise HTTPException(status_code=400, detail=f"Invalid task data: {e}") - except git.GitCommandError as e: - logger.error(f"Git error creating commit: {e}") - raise HTTPException(status_code=500, detail=f"Git operation failed: {e}") - - -@router.get("/{project_id}/git/commits", response_model=CommitListResponse) -async def list_commits( - project_id: int, - branch: Optional[str] = Query(default=None, description="Branch name (default: current)"), - limit: int = Query(default=50, ge=1, le=100, description="Max commits to return"), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """List git commits for a project. - - Args: - project_id: Project ID - branch: Optional branch name (default: current branch) - limit: Maximum number of commits to return (1-100) - db: Database instance - current_user: Authenticated user - - Returns: - List of commits - - Raises: - HTTPException: - - 403: Access denied - - 404: Project not found - - 500: Git operation failed - """ - # Get project and check access - project = get_project_or_404(db, project_id) - check_project_access(db, current_user, project_id) - - # Get workflow manager - workflow_manager = get_git_workflow_manager(project, db) - - # Validate branch name if provided - if branch: - try: - validate_branch_name(branch) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - try: - # Get commit iterator - if branch: - commits_iter = workflow_manager.repo.iter_commits(branch, max_count=limit) - else: - commits_iter = workflow_manager.repo.iter_commits(max_count=limit) - - # Build commit list - commits = [] - for commit in commits_iter: - # Count files changed (if available) - try: - files_changed = commit.stats.total.get("files", 0) if commit.stats else None - except Exception: - files_changed = None - - commits.append( - CommitListItem( - hash=commit.hexsha, - short_hash=commit.hexsha[:7], - message=commit.message.strip().split("\n")[0], # First line only - author=str(commit.author), - timestamp=commit.committed_datetime.astimezone(UTC).isoformat().replace("+00:00", "Z"), - files_changed=files_changed, - ) - ) - - return CommitListResponse(commits=commits) - - except git.GitCommandError as e: - logger.error(f"Git error listing commits: {e}") - raise HTTPException(status_code=500, detail=f"Git operation failed: {e}") - except git.BadName as e: - # Invalid branch/ref name - logger.warning(f"Invalid branch name in list_commits: {e}") - raise HTTPException(status_code=400, detail=f"Invalid branch reference: {branch}") - except (ValueError, KeyError) as e: - logger.error(f"Data error listing commits: {e}") - raise HTTPException(status_code=500, detail=f"Data error: {e}") - - -# ============================================================================ -# Status Endpoint -# ============================================================================ - - -@router.get("/{project_id}/git/status", response_model=GitStatusResponse) -async def get_git_status( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get git working tree status. - - Args: - project_id: Project ID - db: Database instance - current_user: Authenticated user - - Returns: - Git status including current branch and file states - - Raises: - HTTPException: - - 403: Access denied - - 404: Project not found - - 500: Git operation failed - """ - # Get project and check access - project = get_project_or_404(db, project_id) - check_project_access(db, current_user, project_id) - - # Get workflow manager - workflow_manager = get_git_workflow_manager(project, db) - - try: - repo = workflow_manager.repo - - # Get current branch - current_branch = workflow_manager.get_current_branch() - - # Check if dirty - is_dirty = repo.is_dirty(untracked_files=True) - - # Get modified files (tracked, unstaged changes) - modified_files = [item.a_path for item in repo.index.diff(None)] - - # Get untracked files - untracked_files = repo.untracked_files - - # Get staged files (handle repos with no commits/HEAD) - try: - if repo.head.is_valid(): - staged_files = [item.a_path for item in repo.index.diff("HEAD")] - else: - # No HEAD yet - all indexed files are staged - # entries.keys() returns (path, stage) tuples, extract just the path - staged_files = [path for path, _stage in repo.index.entries.keys()] if repo.index.entries else [] - except git.BadName: - # HEAD reference doesn't exist (empty repo) - staged_files = [] - - return GitStatusResponse( - current_branch=current_branch, - is_dirty=is_dirty, - modified_files=modified_files, - untracked_files=list(untracked_files), - staged_files=staged_files, - ) - - except git.GitCommandError as e: - logger.error(f"Git error getting status: {e}") - raise HTTPException(status_code=500, detail=f"Git operation failed: {e}") - except git.InvalidGitRepositoryError as e: - logger.error(f"Invalid git repository: {e}") - raise HTTPException(status_code=500, detail="Invalid git repository") - except (ValueError, KeyError, AttributeError) as e: - logger.error(f"Data error getting git status: {e}") - raise HTTPException(status_code=500, detail=f"Data error: {e}") diff --git a/codeframe/ui/routers/lint.py b/codeframe/ui/routers/lint.py deleted file mode 100644 index 6afce28c..00000000 --- a/codeframe/ui/routers/lint.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Linting endpoints for CodeFRAME. - -This module provides API endpoints for running linters (ruff, eslint), -viewing lint results, trends, and configuration. - -Sprint 9 Phase 5: T115-T119 -""" - -from datetime import datetime, UTC -from pathlib import Path - -from fastapi import APIRouter, HTTPException, Request, Depends - -from codeframe.persistence.database import Database -from codeframe.testing.lint_runner import LintRunner -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager - -router = APIRouter(prefix="/api/lint", tags=["lint"]) - - -@router.get("/results") -async def get_lint_results( - task_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get lint results for a specific task (T116). - - Args: - task_id: Task ID to get lint results for - db: Database connection (injected) - - Returns: - List of lint results with error/warning counts and full output - - Example: - GET /api/lint/results?task_id=123 - """ - # Get task to obtain project_id for authorization - task = db.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - project_id = task.project_id - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - results = db.get_lint_results_for_task(task_id) - return {"task_id": task_id, "results": results} - - -@router.get("/trend") -async def get_lint_trend( - project_id: int, - days: int = 7, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get lint error trend for project over time (T117). - - Args: - project_id: Project ID - days: Number of days to look back (default: 7) - db: Database connection (injected) - - Returns: - List of {date, linter, error_count, warning_count} dictionaries - - Example: - GET /api/lint/trend?project_id=1&days=7 - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - trend = db.get_lint_trend(project_id, days=days) - return {"project_id": project_id, "days": days, "trend": trend} - - -@router.get("/config") -async def get_lint_config( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get current lint configuration for project (T118). - - Args: - project_id: Project ID - db: Database connection (injected) - - Returns: - Lint configuration from pyproject.toml and .eslintrc.json - - Example: - GET /api/lint/config?project_id=1 - """ - # Get project workspace path - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, detail={"error": "Project not found", "project_id": project_id} - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - workspace_path = Path(project.get("workspace_path", ".")) - - # Load config using LintRunner - lint_runner = LintRunner(workspace_path) - - return { - "project_id": project_id, - "config": lint_runner.config, - "has_ruff_config": "ruff" in lint_runner.config, - "has_eslint_config": "eslint" in lint_runner.config, - } - - -@router.post("/run", status_code=202) -async def run_lint_manual( - request: Request, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Trigger manual lint run for specific files or task (T115). - - Args: - request: FastAPI request object - db: Database connection (injected) - - Request Body: - - project_id: int - - task_id: int (optional) - - files: list[str] (optional, if not using task_id) - - Returns: - 202 Accepted: Lint results with error/warning counts - - Example: - POST /api/lint/run - { - "project_id": 1, - "task_id": 123 - } - """ - data = await request.json() - project_id = data.get("project_id") - task_id = data.get("task_id") - files = data.get("files", []) - - if not project_id: - raise HTTPException(status_code=422, detail="project_id is required") - - # Get project workspace - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, detail={"error": "Project not found", "project_id": project_id} - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - workspace_path = Path(project.get("workspace_path", ".")) - - # Get files to lint - if task_id: - # Verify task exists - task = db.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - # Verify task belongs to requested project (security: prevent cross-project access) - if task.project_id != project_id: - raise HTTPException( - status_code=403, - detail="Task does not belong to the specified project" - ) - - # TODO: Implement task-based file discovery. The Task model doesn't currently - # track modified files. Options: (1) add files_modified field to Task model, - # (2) query git diff for commits associated with task, (3) track via changelog. - # For now, require explicit files list when using task_id. - if not files: - raise HTTPException( - status_code=422, - detail="Task-based file discovery not yet implemented. Please provide explicit 'files' list." - ) - elif not files: - raise HTTPException(status_code=422, detail="Either task_id or files must be provided") - - # Convert to Path objects - file_paths = [Path(workspace_path) / f for f in files] - - # Broadcast lint started (T119) - await manager.broadcast( - { - "type": "lint_started", - "project_id": project_id, - "task_id": task_id, - "file_count": len(file_paths), - "timestamp": datetime.now(UTC).isoformat(), - } - ) - - # Run lint - lint_runner = LintRunner(workspace_path) - try: - results = await lint_runner.run_lint(file_paths) - except Exception as e: - # Broadcast lint failed (T119) - await manager.broadcast( - { - "type": "lint_failed", - "project_id": project_id, - "task_id": task_id, - "error": str(e), - "timestamp": datetime.now(UTC).isoformat(), - } - ) - raise HTTPException(status_code=500, detail=str(e)) - - # Store results if task_id provided - if task_id: - for result in results: - result.task_id = task_id - db.create_lint_result( - task_id=result.task_id, - linter=result.linter, - error_count=result.error_count, - warning_count=result.warning_count, - files_linted=result.files_linted, - output=result.output, - ) - - # Check quality gate - has_errors = lint_runner.has_critical_errors(results) - - # Broadcast lint completed (T119) - total_errors = sum(r.error_count for r in results) - total_warnings = sum(r.warning_count for r in results) - - await manager.broadcast( - { - "type": "lint_completed", - "project_id": project_id, - "task_id": task_id, - "has_errors": has_errors, - "error_count": total_errors, - "warning_count": total_warnings, - "results": [r.to_dict() for r in results], - "timestamp": datetime.now(UTC).isoformat(), - } - ) - - # Prepare response - return { - "status": "completed", - "project_id": project_id, - "task_id": task_id, - "has_errors": has_errors, - "results": [ - { - "linter": r.linter, - "error_count": r.error_count, - "warning_count": r.warning_count, - "files_linted": r.files_linted, - } - for r in results - ], - } diff --git a/codeframe/ui/routers/metrics.py b/codeframe/ui/routers/metrics.py deleted file mode 100644 index da1a9a40..00000000 --- a/codeframe/ui/routers/metrics.py +++ /dev/null @@ -1,564 +0,0 @@ -"""Metrics API router for CodeFRAME. - -This module provides endpoints for token usage and cost metrics, -including project-level and agent-level breakdowns. - -Endpoints: - - GET /api/projects/{project_id}/metrics/tokens - Get token usage metrics - - GET /api/projects/{project_id}/metrics/costs - Get cost metrics - - GET /api/agents/{agent_id}/metrics - Get agent-specific metrics -""" - -import logging -from datetime import datetime, timezone -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException - -from codeframe.lib.metrics_tracker import MetricsTracker -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User - -# Module logger -logger = logging.getLogger(__name__) - -# Create router for metrics endpoints -router = APIRouter(tags=["metrics"]) - - -@router.get("/api/projects/{project_id}/metrics/tokens") -async def get_project_token_metrics( - project_id: int, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get token usage metrics for a project (T127). - - Sprint 10 - Phase 5: Metrics & Cost Tracking - - Returns token usage records for a project, optionally filtered by date range. - Includes timeline statistics and aggregated token counts. - - Args: - project_id: Project ID to get token metrics for - start_date: Optional start date (ISO 8601 format, e.g., '2025-11-01T00:00:00Z') - end_date: Optional end date (ISO 8601 format, e.g., '2025-11-30T23:59:59Z') - db: Database instance (injected) - - Returns: - 200 OK: Token usage records with timeline stats - { - "project_id": int, - "total_tokens": int, - "total_calls": int, - "total_cost_usd": float, - "date_range": { - "start": str | null, - "end": str | null - }, - "usage_records": [ - { - "id": int, - "task_id": int | null, - "agent_id": str, - "model_name": str, - "input_tokens": int, - "output_tokens": int, - "estimated_cost_usd": float, - "call_type": str, - "timestamp": str - }, - ... - ] - } - 400 Bad Request: Invalid date format - 404 Not Found: Project not found - 500 Internal Server Error: Database or processing error - - Example: - GET /api/projects/1/metrics/tokens - GET /api/projects/1/metrics/tokens?start_date=2025-11-01T00:00:00Z&end_date=2025-11-30T23:59:59Z - """ - # Validate project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Parse and validate date parameters - start_dt = None - end_dt = None - - try: - if start_date: - start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00")) - if end_date: - end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00")) - except ValueError as e: - raise HTTPException( - status_code=400, - detail=f"Invalid date format. Use ISO 8601 format (e.g., '2025-11-01T00:00:00Z'): {str(e)}", - ) - - try: - # Get token usage stats using MetricsTracker - tracker = MetricsTracker(db=db) - stats = await tracker.get_token_usage_stats( - project_id=project_id, start_date=start_dt, end_date=end_dt - ) - - # Get detailed usage records - usage_records = db.get_token_usage( - project_id=project_id, start_date=start_dt, end_date=end_dt - ) - - # Build response - return { - "project_id": project_id, - "total_tokens": stats["total_tokens"], - "total_calls": stats["total_calls"], - "total_cost_usd": stats["total_cost_usd"], - "date_range": stats["date_range"], - "usage_records": usage_records, - } - - except Exception as e: - logger.error(f"Failed to get token metrics for project {project_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to retrieve token metrics: {str(e)}") - - -def _parse_date(date_str: str) -> datetime: - """Parse a date string in multiple formats. - - Accepts: - - ISO 8601 with time: '2025-01-01T00:00:00Z' - - Date only: '2025-01-01' (converts to start of day UTC) - - Args: - date_str: Date string to parse - - Returns: - Parsed datetime object - - Raises: - ValueError: If date format is invalid - """ - # Try full ISO 8601 format first - if "T" in date_str: - return datetime.fromisoformat(date_str.replace("Z", "+00:00")) - - # Try date-only format (yyyy-MM-dd) - try: - parsed = datetime.strptime(date_str, "%Y-%m-%d") - return parsed.replace(tzinfo=timezone.utc) - except ValueError: - raise ValueError( - f"Invalid date format: '{date_str}'. " - "Use ISO 8601 format (e.g., '2025-01-01T00:00:00Z' or '2025-01-01')" - ) - - -@router.get("/api/projects/{project_id}/metrics/tokens/timeseries") -async def get_project_token_timeseries( - project_id: int, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - interval: str = "day", - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get token usage time series data for charting. - - Returns token usage records aggregated by time intervals (hour, day, or week) - for visualization in charts. Each data point contains aggregated token counts - and costs for that time bucket. - - Args: - project_id: Project ID to get time series for - start_date: Start date (required). Accepts: - - ISO 8601: '2025-01-01T00:00:00Z' - - Date only: '2025-01-01' - end_date: End date (required). Same formats as start_date. - interval: Time interval for grouping ('hour', 'day', 'week'). Default: 'day' - db: Database instance (injected) - current_user: Authenticated user (injected) - - Returns: - 200 OK: Array of time series data points - [ - { - "timestamp": "2025-01-01T00:00:00Z", - "input_tokens": 1000, - "output_tokens": 500, - "total_tokens": 1500, - "cost_usd": 0.0105 - }, - ... - ] - 400 Bad Request: Missing required dates, invalid date format, or invalid interval - 404 Not Found: Project not found - 403 Forbidden: Access denied - 500 Internal Server Error: Processing error - - Example: - GET /api/projects/1/metrics/tokens/timeseries?start_date=2025-01-01&end_date=2025-01-07&interval=day - """ - # Validate required date parameters - if not start_date or not end_date: - raise HTTPException( - status_code=400, - detail="Both start_date and end_date are required for time series data", - ) - - # Validate project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Parse and validate date parameters - try: - start_dt = _parse_date(start_date) - end_dt = _parse_date(end_date) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Validate interval - valid_intervals = ("hour", "day", "week") - if interval not in valid_intervals: - raise HTTPException( - status_code=400, - detail=f"Invalid interval '{interval}'. Must be one of: {', '.join(valid_intervals)}", - ) - - try: - # Get time series data using MetricsTracker - tracker = MetricsTracker(db=db) - timeseries = await tracker.get_token_usage_timeseries( - project_id=project_id, - start_date=start_dt, - end_date=end_dt, - interval=interval, - ) - - logger.info( - f"Retrieved time series for project {project_id}: " - f"{len(timeseries)} data points ({interval} interval)" - ) - - return timeseries - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error( - f"Failed to get token time series for project {project_id}: {e}", - exc_info=True, - ) - raise HTTPException( - status_code=500, detail=f"Failed to retrieve token time series: {str(e)}" - ) - - -@router.get("/api/projects/{project_id}/metrics/costs") -async def get_project_cost_metrics( - project_id: int, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get cost breakdown for a project (T128). - - Sprint 10 - Phase 5: Metrics & Cost Tracking - - Returns total costs and breakdowns by agent and model for a project. - Useful for understanding cost allocation and identifying high-cost operations. - Optionally filter by date range. - - Args: - project_id: Project ID to get cost breakdown for - start_date: Optional start date. Accepts: - - ISO 8601: '2025-01-01T00:00:00Z' - - Date only: '2025-01-01' - end_date: Optional end date. Same formats as start_date. - db: Database instance (injected) - current_user: Authenticated user (injected) - - Returns: - 200 OK: Cost breakdown - { - "project_id": int, - "total_cost_usd": float, - "total_tokens": int, - "total_calls": int, - "by_agent": [ - { - "agent_id": str, - "cost_usd": float, - "tokens": int, - "calls": int - }, - ... - ], - "by_model": [ - { - "model_name": str, - "cost_usd": float, - "total_tokens": int, - "total_calls": int - }, - ... - ] - } - 400 Bad Request: Invalid date format - 404 Not Found: Project not found - 403 Forbidden: Access denied - 500 Internal Server Error: Database or processing error - - Example: - GET /api/projects/1/metrics/costs - GET /api/projects/1/metrics/costs?start_date=2025-01-01&end_date=2025-01-07 - Response: { - "project_id": 1, - "total_cost_usd": 0.125, - "total_tokens": 15000, - "total_calls": 10, - "by_agent": [ - {"agent_id": "backend-001", "cost_usd": 0.075, "tokens": 9000, "calls": 6}, - {"agent_id": "review-001", "cost_usd": 0.05, "tokens": 6000, "calls": 4} - ], - "by_model": [ - {"model_name": "claude-sonnet-4-5", "cost_usd": 0.125, "total_tokens": 15000, "total_calls": 10} - ] - } - """ - # Validate project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Parse optional date parameters - start_dt = None - end_dt = None - try: - if start_date: - start_dt = _parse_date(start_date) - if end_date: - end_dt = _parse_date(end_date) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - try: - # Get project costs using MetricsTracker - tracker = MetricsTracker(db=db) - costs = await tracker.get_project_costs( - project_id=project_id, start_date=start_dt, end_date=end_dt - ) - - logger.info( - f"Retrieved cost metrics for project {project_id}: ${costs['total_cost_usd']:.6f}" - ) - - return costs - - except Exception as e: - logger.error(f"Failed to get cost metrics for project {project_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to retrieve cost metrics: {str(e)}") - - -@router.get("/api/agents/{agent_id}/metrics") -async def get_agent_metrics( - agent_id: str, - project_id: Optional[int] = None, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get metrics for a specific agent (T129). - - Sprint 10 - Phase 5: Metrics & Cost Tracking - - Returns cost and usage statistics for a specific agent, optionally filtered - by project. Includes breakdowns by call type and project. - - Args: - agent_id: Agent ID to get metrics for - project_id: Optional project ID to filter metrics (query parameter) - db: Database instance (injected) - - Returns: - 200 OK: Agent metrics - { - "agent_id": str, - "total_cost_usd": float, - "total_tokens": int, - "total_calls": int, - "by_call_type": [ - { - "call_type": str, - "cost_usd": float, - "calls": int - }, - ... - ], - "by_project": [ - { - "project_id": int, - "cost_usd": float - }, - ... - ] - } - 500 Internal Server Error: Database or processing error - - Example: - GET /api/agents/backend-001/metrics - GET /api/agents/backend-001/metrics?project_id=1 - Response: { - "agent_id": "backend-001", - "total_cost_usd": 0.085, - "total_tokens": 12000, - "total_calls": 8, - "by_call_type": [ - {"call_type": "task_execution", "cost_usd": 0.06, "calls": 5}, - {"call_type": "code_review", "cost_usd": 0.025, "calls": 3} - ], - "by_project": [ - {"project_id": 1, "cost_usd": 0.085} - ] - } - """ - # Authorization check - if project_id provided, verify access - if project_id is not None: - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - try: - # Get agent costs using MetricsTracker - tracker = MetricsTracker(db=db) - costs = await tracker.get_agent_costs(agent_id=agent_id) - - # Security: Filter by_project to only include projects the user has access to - # This prevents cross-project data leakage when project_id is not specified - if project_id is None: - # Get user's accessible projects - user_projects = db.get_user_projects(current_user.id) - accessible_project_ids = {p["id"] for p in user_projects} - - # Filter by_project to only include accessible projects - costs["by_project"] = [ - p for p in costs["by_project"] - if p["project_id"] in accessible_project_ids - ] - - # Recalculate totals based on filtered projects - # Get all usage records for this agent across accessible projects only - all_usage_records = [] - for proj_id in accessible_project_ids: - project_records = db.get_token_usage(agent_id=agent_id, project_id=proj_id) - all_usage_records.extend(project_records) - - # Recalculate aggregates - total_cost = sum(r["estimated_cost_usd"] for r in all_usage_records) - total_tokens = sum(r["input_tokens"] + r["output_tokens"] for r in all_usage_records) - total_calls = len(all_usage_records) - - # Aggregate by call type - call_type_stats = {} - for record in all_usage_records: - call_type = record["call_type"] - if call_type not in call_type_stats: - call_type_stats[call_type] = { - "call_type": call_type, - "cost_usd": 0.0, - "calls": 0, - } - call_type_stats[call_type]["cost_usd"] += record["estimated_cost_usd"] - call_type_stats[call_type]["calls"] += 1 - - # Round costs - for stats in call_type_stats.values(): - stats["cost_usd"] = round(stats["cost_usd"], 6) - - # Update costs with filtered data - costs["total_cost_usd"] = round(total_cost, 6) - costs["total_tokens"] = total_tokens - costs["total_calls"] = total_calls - costs["by_call_type"] = list(call_type_stats.values()) - # by_project already filtered above - - # If project_id is specified, filter the results - if project_id is not None: - # Filter by_project to only include the specified project - filtered_projects = [p for p in costs["by_project"] if p["project_id"] == project_id] - - if not filtered_projects: - # No data for this agent in this project - return { - "agent_id": agent_id, - "total_cost_usd": 0.0, - "total_tokens": 0, - "total_calls": 0, - "by_call_type": [], - "by_project": [], - } - - # Recalculate totals based on filtered project - # We need to get usage records for this specific project - usage_records = db.get_token_usage(agent_id=agent_id, project_id=project_id) - - # Recalculate aggregates - total_cost = sum(r["estimated_cost_usd"] for r in usage_records) - total_tokens = sum(r["input_tokens"] + r["output_tokens"] for r in usage_records) - total_calls = len(usage_records) - - # Aggregate by call type - call_type_stats = {} - for record in usage_records: - call_type = record["call_type"] - if call_type not in call_type_stats: - call_type_stats[call_type] = { - "call_type": call_type, - "cost_usd": 0.0, - "calls": 0, - } - call_type_stats[call_type]["cost_usd"] += record["estimated_cost_usd"] - call_type_stats[call_type]["calls"] += 1 - - # Round costs - for stats in call_type_stats.values(): - stats["cost_usd"] = round(stats["cost_usd"], 6) - - return { - "agent_id": agent_id, - "total_cost_usd": round(total_cost, 6), - "total_tokens": total_tokens, - "total_calls": total_calls, - "by_call_type": list(call_type_stats.values()), - "by_project": [{"project_id": project_id, "cost_usd": round(total_cost, 6)}], - } - - logger.info(f"Retrieved metrics for agent {agent_id}: ${costs['total_cost_usd']:.6f}") - - return costs - - except Exception as e: - logger.error(f"Failed to get metrics for agent {agent_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to retrieve agent metrics: {str(e)}") diff --git a/codeframe/ui/routers/projects.py b/codeframe/ui/routers/projects.py deleted file mode 100644 index 026310e8..00000000 --- a/codeframe/ui/routers/projects.py +++ /dev/null @@ -1,738 +0,0 @@ -"""Project lifecycle and management router. - -This module provides API endpoints for: -- Project CRUD (create, get, update, delete, list) -- Project status and progress -- Project tasks and activity -- Project PRD and issues -- Session lifecycle management -""" - -import logging -import shutil -import sqlite3 -from datetime import datetime, UTC - -from fastapi import APIRouter, Depends, HTTPException, Query, Request - -from codeframe.core.models import TaskStatus -from codeframe.core.session_manager import SessionManager -from codeframe.persistence.database import Database -from codeframe.workspace import WorkspaceManager -from codeframe.ui.dependencies import get_db, get_workspace_manager -from codeframe.auth import get_current_user, User -from codeframe.ui.models import ( - ProjectCreateRequest, - ProjectResponse, - ProjectListResponse, - ProjectStatusResponse, - TaskListResponse, - ActivityListResponse, - PRDResponse, - IssuesListResponse, - SessionStateResponse, - ErrorResponse, - SourceType, -) -from codeframe.lib.rate_limiter import rate_limit_standard - -# Valid task status values for API validation -VALID_TASK_STATUSES = {s.value for s in TaskStatus} - - -logger = logging.getLogger(__name__) - - -router = APIRouter(prefix="/api/projects", tags=["projects"]) - - -def is_hosted_mode() -> bool: - """Check if running in hosted SaaS mode. - - Returns: - True if hosted mode, False if self-hosted - """ - from codeframe.ui.server import get_deployment_mode, DeploymentMode - - return get_deployment_mode() == DeploymentMode.HOSTED - - -@router.get( - "", - response_model=ProjectListResponse, - summary="List all projects", - description="Returns all CodeFRAME projects accessible to the authenticated user. " - "Projects are scoped by user - each user only sees projects they own or have been granted access to.", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated - valid API key or session required"}, - }, -) -@rate_limit_standard() -async def list_projects( - request: Request, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """List all CodeFRAME projects accessible to the authenticated user.""" - - # Get projects accessible to the current user - projects = db.get_user_projects(current_user.id) - - return {"projects": projects} - - -@router.post( - "", - status_code=201, - response_model=ProjectResponse, - summary="Create a new project", - description="Creates a new CodeFRAME project with the specified configuration. " - "Supports multiple source types: git_remote (clone from URL), local_path (link existing directory), " - "upload (file upload), or empty (start fresh). The project is automatically assigned to the authenticated user.", - responses={ - 400: {"model": ErrorResponse, "description": "Invalid request - validation error or malformed input"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Forbidden - source_type='local_path' not available in hosted mode"}, - 409: {"model": ErrorResponse, "description": "Conflict - project with this name already exists"}, - 500: {"model": ErrorResponse, "description": "Server error - database or workspace creation failed"}, - }, -) -@rate_limit_standard() -async def create_project( - request: Request, - body: ProjectCreateRequest, - db: Database = Depends(get_db), - workspace_manager: WorkspaceManager = Depends(get_workspace_manager), - current_user: User = Depends(get_current_user), -): - """Create a new project. - - Args: - body: Project creation request with name, description, source config - db: Database connection - workspace_manager: Workspace manager - current_user: Authenticated user creating the project - - Returns: - Created project details - """ - # Security: Hosted mode cannot access user's local filesystem - if is_hosted_mode() and body.source_type == SourceType.LOCAL_PATH: - raise HTTPException( - status_code=403, detail="source_type='local_path' not available in hosted mode" - ) - - # Check for duplicate project name - try: - existing_projects = db.list_projects() - except sqlite3.Error as e: - logger.error(f"Database error listing projects: {str(e)}") - raise HTTPException( - status_code=500, detail="Database error occurred. Please try again later." - ) - - if any(p["name"] == body.name for p in existing_projects): - raise HTTPException( - status_code=409, detail=f"Project with name '{body.name}' already exists" - ) - - # Create project record first (to get ID) - try: - project_id = db.create_project( - name=body.name, - description=body.description, - source_type=body.source_type.value, - source_location=body.source_location, - source_branch=body.source_branch, - workspace_path="", # Will be updated after workspace creation - user_id=current_user.id, # Assign project to current user - ) - except sqlite3.Error as e: - logger.error(f"Database error creating project: {str(e)}") - raise HTTPException( - status_code=500, detail="Database error occurred. Please try again later." - ) - - # Create workspace - try: - workspace_path = workspace_manager.create_workspace( - project_id=project_id, - source_type=body.source_type, - source_location=body.source_location, - source_branch=body.source_branch, - ) - - # Update project with workspace path and git status - try: - db.update_project( - project_id, {"workspace_path": str(workspace_path), "git_initialized": True} - ) - except sqlite3.Error as db_error: - # Database error during update - cleanup and fail - logger.error(f"Database error updating project {project_id}: {db_error}") - - # Best-effort cleanup: delete project record - try: - db.delete_project(project_id) - except sqlite3.Error as cleanup_db_error: - logger.error( - f"Failed to delete project {project_id} during cleanup: {cleanup_db_error}" - ) - - # Best-effort cleanup: remove workspace directory (use actual workspace_path) - if workspace_path.exists(): - try: - shutil.rmtree(workspace_path) - logger.info(f"Cleaned up workspace directory: {workspace_path}") - except (OSError, PermissionError) as cleanup_fs_error: - logger.error( - f"Failed to clean up workspace {workspace_path}: {cleanup_fs_error}" - ) - - raise HTTPException( - status_code=500, detail="Database error occurred. Please try again later." - ) - - except HTTPException: - # Re-raise HTTPException from database error handling above - raise - - except Exception as e: - # Cleanup: delete project and workspace if creation fails - logger.error(f"Workspace creation failed for project {project_id}: {e}") - - # Best-effort cleanup: delete project record - try: - db.delete_project(project_id) - except sqlite3.Error as cleanup_db_error: - logger.error( - f"Failed to delete project {project_id} during cleanup: {cleanup_db_error}" - ) - - # Explicitly clean up workspace directory if it exists - # (Defense in depth: WorkspaceManager has cleanup, but this ensures - # orphaned directories are removed even if that cleanup fails) - workspace_path = workspace_manager.workspace_root / str(project_id) - if workspace_path.exists(): - try: - shutil.rmtree(workspace_path) - logger.info(f"Cleaned up orphaned workspace: {workspace_path}") - except (OSError, PermissionError) as cleanup_error: - logger.error(f"Failed to clean up workspace {workspace_path}: {cleanup_error}") - - raise HTTPException( - status_code=500, detail="Workspace creation failed. Please try again later." - ) - - # Return project details - try: - project = db.get_project(project_id) - except sqlite3.Error as e: - logger.error(f"Database error retrieving project {project_id}: {str(e)}") - raise HTTPException( - status_code=500, detail="Database error occurred. Please try again later." - ) - - return ProjectResponse( - id=project["id"], - name=project["name"], - status=project["status"], - phase=project["phase"], - created_at=project["created_at"], - config=project["config"], - ) - - -@router.get( - "/{project_id}", - response_model=ProjectResponse, - summary="Get project by ID", - description="Retrieves detailed information about a specific project. " - "Requires the authenticated user to have access to the project (owner or collaborator).", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied - user doesn't have access to this project"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_standard() -async def get_project( - request: Request, - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get project by ID. - - Args: - project_id: Project ID to retrieve - - Returns: - Project details - - Raises: - HTTPException: - - 403: Access denied (user doesn't have access to this project) - - 404: Project not found - """ - # Get project from database - project = db.get_project(project_id) - - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - return 403 if user lacks access to existing project - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - return { - "id": project["id"], - "name": project["name"], - "description": project.get("description", ""), - "status": project["status"], - "phase": project["phase"], - "created_at": project.get("created_at"), - "workspace_path": project.get("workspace_path"), - "config": project.get("config"), - } - - -@router.get( - "/{project_id}/status", - response_model=ProjectStatusResponse, - summary="Get project status and progress", - description="Returns comprehensive project status including execution state, lifecycle phase, " - "and progress metrics. Progress is calculated from task completion rates.", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_standard() -async def get_project_status( - request: Request, - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get comprehensive project status.""" - # Get project from database - project = db.get_project(project_id) - - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Calculate progress metrics (cf-46) - progress = db._calculate_project_progress(project_id) - - return { - "project_id": project["id"], - "name": project["name"], - "status": project["status"], - "phase": project["phase"], - "workflow_step": 1, # Project doesn't have workflow_step, default to 1 - "progress": progress, - } - - -@router.get( - "/{project_id}/tasks", - response_model=TaskListResponse, - summary="Get project tasks with filtering and pagination", - description="Returns tasks for a project with optional status filtering and pagination. " - "Tasks are returned with full details including status, priority, and assignment info.", - responses={ - 400: {"model": ErrorResponse, "description": "Invalid status value provided"}, - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - 422: {"model": ErrorResponse, "description": "Invalid parameters (negative offset, limit out of range)"}, - }, -) -@rate_limit_standard() -async def get_tasks( - request: Request, - project_id: int, - status: str | None = Query( - default=None, - description="Filter by task status. Valid values: 'pending', 'assigned', 'in_progress', " - "'blocked', 'completed', 'failed'", - example="in_progress" - ), - limit: int = Query( - default=50, - ge=1, - le=1000, - description="Maximum number of tasks to return (1-1000)", - example=50 - ), - offset: int = Query( - default=0, - ge=0, - description="Number of tasks to skip for pagination", - example=0 - ), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get project tasks with filtering and pagination. - - Authorization: Requires project access. - - Args: - project_id: Project ID to get tasks for - status: Optional filter by task status. Valid values: - 'pending', 'assigned', 'in_progress', 'blocked', 'completed', 'failed' - limit: Maximum number of tasks to return (1-1000, default: 50) - offset: Number of tasks to skip for pagination (>=0, default: 0) - db: Database instance (injected) - - Returns: - Dictionary with: - - tasks: List[Dict] - Paginated list of task dictionaries - - total: int - Total number of tasks matching the filter (before pagination) - - Raises: - HTTPException: - - 400: Invalid status value - - 404: Project not found - - 422: Invalid parameters (negative offset, limit out of range) - - 500: Database error - - Security Notes: - - Input validation: limit constrained to 1-1000, offset must be >=0 - - Status parameter validated against TaskStatus enum values - - Authorization: User must have project access (owner/collaborator/viewer) - """ - try: - # Validate status parameter against TaskStatus enum values - if status is not None and status not in VALID_TASK_STATUSES: - raise HTTPException( - status_code=400, - detail=f"Invalid status '{status}'. Valid values: {', '.join(sorted(VALID_TASK_STATUSES))}" - ) - - # Validate project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - # if not db.user_has_project_access(current_user.id, project_id): - # raise HTTPException(status_code=403, detail="Access denied") - - # Query all tasks for the project - tasks = db.get_project_tasks(project_id) - - # Apply status filtering if provided - # NOTE: Client-side filtering used here. For large datasets (1000+ tasks), - # consider adding database-level filtering in future optimization. - if status is not None: - tasks = [t for t in tasks if t.status.value == status] - - # Calculate total count before pagination - total = len(tasks) - - # Apply pagination - tasks = tasks[offset : offset + limit] - - # Convert Task objects to dictionaries for JSON serialization - tasks_dicts = [t.to_dict() for t in tasks] - - return {"tasks": tasks_dicts, "total": total} - - except sqlite3.Error as e: - logger.error(f"Database error fetching tasks for project {project_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Error fetching tasks") - - -@router.get( - "/{project_id}/activity", - response_model=ActivityListResponse, - summary="Get recent project activity", - description="Returns the activity log for a project, showing recent actions like task creation, " - "completion, blocker events, and phase transitions. Results are ordered by most recent first.", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - 500: {"model": ErrorResponse, "description": "Error fetching activity"}, - }, -) -@rate_limit_standard() -async def get_activity( - request: Request, - project_id: int, - limit: int = Query( - default=50, - ge=1, - le=500, - description="Maximum number of activity items to return (1-500)", - example=50 - ), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get recent activity log.""" - try: - # Authorization check - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Query changelog table for activity - activity_items = db.get_recent_activity(project_id, limit=limit) - - return {"activity": activity_items} - except Exception as e: - logger.error(f"Error fetching activity: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error fetching activity: {str(e)}") - - -@router.get( - "/{project_id}/prd", - response_model=PRDResponse, - summary="Get project PRD", - description="Returns the Product Requirements Document (PRD) for a project. " - "The PRD contains the project specification used to generate tasks. " - "Status indicates availability: 'available' (ready), 'generating' (in progress), 'not_found' (no PRD).", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_standard() -async def get_project_prd( - request: Request, - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get PRD for a project (cf-26). - - Sprint 2 Foundation Contract: - Returns PRDResponse with: - - project_id: string (not int) - - prd_content: string - - generated_at: ISODate (RFC 3339 with timezone) - - updated_at: ISODate (RFC 3339 with timezone) - - status: 'available' | 'generating' | 'not_found' - - Args: - project_id: Project ID - - Returns: - PRDResponse dictionary - - Raises: - HTTPException: - - 404: Project not found - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get PRD from database - prd_data = db.get_prd(project_id) - - if not prd_data: - # PRD not found - return empty response - return { - "project_id": str(project_id), - "prd_content": "", - "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - "updated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - "status": "not_found", - } - - # PRD exists - return it - return { - "project_id": str(project_id), - "prd_content": prd_data["prd_content"], - "generated_at": prd_data["generated_at"], - "updated_at": prd_data["updated_at"], - "status": "available", - } - - -@router.get( - "/{project_id}/issues", - response_model=IssuesListResponse, - summary="Get project issues", - description="Returns issues (high-level work items) for a project. Issues group related tasks together. " - "Use include='tasks' to also return the tasks under each issue.", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_standard() -async def get_project_issues( - request: Request, - project_id: int, - include: str = Query( - default=None, - description="Set to 'tasks' to include tasks array under each issue", - example="tasks" - ), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get issues for a project (cf-26). - - Sprint 2 Foundation Contract: - Returns IssuesResponse with: - - issues: Issue[] - - total_issues: number - - total_tasks: number - - next_cursor?: string (optional) - - prev_cursor?: string (optional) - - Each Issue contains: - - id: string (not int) - - issue_number: string - - title: string - - description: string - - status: WorkStatus - - priority: number - - depends_on: string[] - - proposed_by: 'agent' | 'human' - - created_at: ISODate (RFC 3339) - - updated_at: ISODate (RFC 3339) - - completed_at: ISODate | null - - tasks?: Task[] (if include=tasks) - - Args: - project_id: Project ID - include: Optional query param, 'tasks' to include tasks - - Returns: - IssuesResponse dictionary - - Raises: - HTTPException: - - 404: Project not found - """ - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Determine if tasks should be included - include_tasks = include == "tasks" - - # Get issues from database - issues_data = db.get_issues_with_tasks(project_id, include_tasks) - - # Return according to API contract - return issues_data - - -@router.get( - "/{project_id}/session", - tags=["session"], - response_model=SessionStateResponse, - summary="Get current session state", - description="Returns the current session state for a project including last session summary, " - "recommended next actions, overall progress percentage, and any active blockers requiring attention. " - "Returns empty state if no session file exists.", - responses={ - 401: {"model": ErrorResponse, "description": "Not authenticated"}, - 403: {"model": ErrorResponse, "description": "Access denied"}, - 404: {"model": ErrorResponse, "description": "Project not found"}, - }, -) -@rate_limit_standard() -async def get_session_state( - request: Request, - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get current session state for project (T028). - - Args: - project_id: Project ID - - Returns: - Session state with last session summary, next actions, progress, blockers - Returns empty state if no session file exists - - Raises: - HTTPException: - - 404: Project not found - - Example: - GET /api/projects/1/session - """ - # Get project - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, detail={"error": "Project not found", "project_id": project_id} - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get project path - workspace_path = project.get("workspace_path") - if not workspace_path: - # Return empty state if no workspace path - return { - "last_session": { - "summary": "No previous session", - "timestamp": datetime.now(UTC).isoformat(), - }, - "next_actions": [], - "progress_pct": 0.0, - "active_blockers": [], - } - - # Load session state - session_mgr = SessionManager(workspace_path) - session = session_mgr.load_session() - - if not session: - # Return empty state - return { - "last_session": { - "summary": "No previous session", - "timestamp": datetime.now(UTC).isoformat(), - }, - "next_actions": [], - "progress_pct": 0.0, - "active_blockers": [], - } - - # Return session state (omit completed_tasks and current_plan for API response) - return { - "last_session": { - "summary": session["last_session"]["summary"], - "timestamp": session["last_session"]["timestamp"], - }, - "next_actions": session.get("next_actions", []), - "progress_pct": session.get("progress_pct", 0.0), - "active_blockers": session.get("active_blockers", []), - } diff --git a/codeframe/ui/routers/projects_v2.py b/codeframe/ui/routers/projects_v2.py deleted file mode 100644 index e0227967..00000000 --- a/codeframe/ui/routers/projects_v2.py +++ /dev/null @@ -1,253 +0,0 @@ -"""V2 Projects router - delegates to core modules. - -This module provides v2-style API endpoints for project/workspace management -that delegate to core modules. It uses the v2 Workspace model. - -The v1 router (projects.py) remains for backwards compatibility. -""" - -import logging -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel - -from codeframe.core.workspace import Workspace -from codeframe.lib.rate_limiter import rate_limit_standard -from codeframe.core import project_status -from codeframe.ui.dependencies import get_v2_workspace - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/v2/projects", tags=["projects-v2"]) - - -# ============================================================================ -# Response Models -# ============================================================================ - - -class TaskCountsResponse(BaseModel): - """Response model for task counts.""" - - total: int - backlog: int - ready: int - in_progress: int - done: int - blocked: int - failed: int - - -class ProgressMetricsResponse(BaseModel): - """Response model for progress metrics.""" - - completed_count: int - total_count: int - progress_percentage: float - open_blockers: int - - -class WorkspaceStatusResponse(BaseModel): - """Response model for workspace status.""" - - workspace_id: str - workspace_name: str - repo_path: str - tech_stack: Optional[str] - task_counts: TaskCountsResponse - progress: ProgressMetricsResponse - created_at: str - - -class SessionStateResponse(BaseModel): - """Response model for session state.""" - - has_session: bool - last_session_summary: str - last_session_timestamp: str - next_actions: list[str] - progress_pct: float - active_blockers: list[dict] - - -# ============================================================================ -# Project Endpoints -# ============================================================================ - - -@router.get("/status", response_model=WorkspaceStatusResponse) -@rate_limit_standard() -async def get_workspace_status( - request: Request, - workspace: Workspace = Depends(get_v2_workspace), -) -> WorkspaceStatusResponse: - """Get comprehensive status for a workspace. - - Combines task counts, progress metrics, and workspace metadata. - - This is the v2 equivalent of `cf status`. - - Args: - request: HTTP request for rate limiting - workspace: v2 Workspace - - Returns: - WorkspaceStatus with all status information - """ - try: - status = project_status.get_workspace_status(workspace) - - return WorkspaceStatusResponse( - workspace_id=status.workspace_id, - workspace_name=status.workspace_name, - repo_path=status.repo_path, - tech_stack=status.tech_stack, - task_counts=TaskCountsResponse( - total=status.task_counts.total, - backlog=status.task_counts.backlog, - ready=status.task_counts.ready, - in_progress=status.task_counts.in_progress, - done=status.task_counts.done, - blocked=status.task_counts.blocked, - failed=status.task_counts.failed, - ), - progress=ProgressMetricsResponse( - completed_count=status.progress.completed_count, - total_count=status.progress.total_count, - progress_percentage=status.progress.progress_percentage, - open_blockers=status.progress.open_blockers, - ), - created_at=status.created_at.isoformat(), - ) - - except Exception as e: - logger.error(f"Failed to get workspace status: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/progress", response_model=ProgressMetricsResponse) -@rate_limit_standard() -async def get_progress( - request: Request, - workspace: Workspace = Depends(get_v2_workspace), -) -> ProgressMetricsResponse: - """Get progress metrics for a workspace. - - Returns completion progress and blocker count. - - Args: - workspace: v2 Workspace - - Returns: - ProgressMetrics with completion progress - """ - try: - progress = project_status.get_progress_metrics(workspace) - - return ProgressMetricsResponse( - completed_count=progress.completed_count, - total_count=progress.total_count, - progress_percentage=progress.progress_percentage, - open_blockers=progress.open_blockers, - ) - - except Exception as e: - logger.error(f"Failed to get progress metrics: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/task-counts", response_model=TaskCountsResponse) -@rate_limit_standard() -async def get_task_counts( - request: Request, - workspace: Workspace = Depends(get_v2_workspace), -) -> TaskCountsResponse: - """Get task count statistics for a workspace. - - Args: - workspace: v2 Workspace - - Returns: - TaskCounts with counts for each status - """ - try: - counts = project_status.get_task_counts(workspace) - - return TaskCountsResponse( - total=counts.total, - backlog=counts.backlog, - ready=counts.ready, - in_progress=counts.in_progress, - done=counts.done, - blocked=counts.blocked, - failed=counts.failed, - ) - - except Exception as e: - logger.error(f"Failed to get task counts: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/session", response_model=SessionStateResponse) -@rate_limit_standard() -async def get_session_state( - request: Request, - workspace: Workspace = Depends(get_v2_workspace), -) -> SessionStateResponse: - """Get current session state for a workspace. - - Loads session state from .codeframe/session_state.json. - - This is the v2 equivalent of `GET /api/projects/{id}/session`. - - Args: - workspace: v2 Workspace - - Returns: - SessionState with session information - """ - try: - session = project_status.get_session_state(workspace) - - return SessionStateResponse( - has_session=session.has_session, - last_session_summary=session.last_session_summary, - last_session_timestamp=session.last_session_timestamp, - next_actions=session.next_actions, - progress_pct=session.progress_pct, - active_blockers=session.active_blockers, - ) - - except Exception as e: - logger.error(f"Failed to get session state: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/session") -@rate_limit_standard() -async def clear_session_state( - request: Request, - workspace: Workspace = Depends(get_v2_workspace), -) -> dict: - """Clear session state for a workspace. - - Removes .codeframe/session_state.json. - - Args: - workspace: v2 Workspace - - Returns: - Success confirmation - """ - try: - project_status.clear_session_state(workspace) - - return { - "success": True, - "message": "Session state cleared", - } - - except Exception as e: - logger.error(f"Failed to clear session state: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) diff --git a/codeframe/ui/routers/prs.py b/codeframe/ui/routers/prs.py deleted file mode 100644 index d6345d83..00000000 --- a/codeframe/ui/routers/prs.py +++ /dev/null @@ -1,422 +0,0 @@ -"""Pull Request management router. - -This module provides API endpoints for: -- Creating pull requests via GitHub API -- Listing and getting PR details -- Merging and closing PRs - -Part of Sprint 11 - GitHub PR Integration. -""" - -import logging -from typing import Literal, Optional - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field - -from codeframe.core.config import GlobalConfig, load_environment -from codeframe.git.github_integration import GitHubIntegration, GitHubAPIError -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.ui.shared import manager -from codeframe.ui.websocket_broadcasts import ( - broadcast_pr_created, - broadcast_pr_merged, - broadcast_pr_closed, -) -from codeframe.auth import get_current_user, User - - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/projects/{project_id}/prs", tags=["pull-requests"]) - - -def get_global_config() -> GlobalConfig: - """Get global configuration with GitHub settings.""" - load_environment() - return GlobalConfig() - - -# ============================================================================ -# Request/Response Models -# ============================================================================ - - -class CreatePRRequest(BaseModel): - """Request to create a pull request.""" - - branch: str = Field(..., description="Head branch with changes") - title: str = Field(..., description="PR title") - body: str = Field("", description="PR description") - base: str = Field("main", description="Base branch to merge into") - - -class CreatePRResponse(BaseModel): - """Response after creating a PR.""" - - pr_id: int - pr_number: int - pr_url: str - status: str - - -class MergePRRequest(BaseModel): - """Request to merge a pull request.""" - - method: Literal["squash", "merge", "rebase"] = Field( - "squash", description="Merge method (squash, merge, rebase)" - ) - - -class MergePRResponse(BaseModel): - """Response after merging a PR.""" - - merged: bool - merge_commit_sha: Optional[str] - - -class ClosePRResponse(BaseModel): - """Response after closing a PR.""" - - closed: bool - - -class PRListResponse(BaseModel): - """Response containing list of PRs.""" - - prs: list - total: int - - -# ============================================================================ -# Helper Functions -# ============================================================================ - - -def validate_github_config(config: GlobalConfig) -> tuple[str, str]: - """Validate that GitHub is properly configured. - - Returns: - Tuple of (github_token, github_repo) - - Raises: - HTTPException: If GitHub config is missing - """ - if not config.github_token or not config.github_repo: - raise HTTPException( - status_code=400, - detail="GitHub integration not configured. Set GITHUB_TOKEN and GITHUB_REPO environment variables.", - ) - return config.github_token, config.github_repo - - -async def validate_project_access( - project_id: int, - db: Database, - user: User, -) -> dict: - """Validate project exists and user has access. - - Returns: - Project dict - - Raises: - HTTPException: If project not found or access denied - """ - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - if not db.user_has_project_access(user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - return project - - -# ============================================================================ -# Endpoints -# ============================================================================ - - -@router.post("", status_code=201, response_model=CreatePRResponse) -async def create_pull_request( - project_id: int, - request: CreatePRRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Create a new pull request via GitHub API. - - Args: - project_id: Project ID - request: PR creation request with branch, title, body, base - - Returns: - Created PR details - - Raises: - HTTPException: - - 400: GitHub not configured - - 403: Access denied - - 404: Project not found - - 422: GitHub API error - """ - # Validate access - await validate_project_access(project_id, db, current_user) - - # Get GitHub config - config = get_global_config() - github_token, github_repo = validate_github_config(config) - - # Create PR via GitHub API - gh: Optional[GitHubIntegration] = None - try: - gh = GitHubIntegration(token=github_token, repo=github_repo) - pr_details = await gh.create_pull_request( - branch=request.branch, - title=request.title, - body=request.body, - base=request.base, - ) - - # Store in database - pr_id = db.pull_requests.create_pr( - project_id=project_id, - issue_id=None, # Can be linked later - branch_name=request.branch, - title=request.title, - body=request.body, - base_branch=request.base, - head_branch=request.branch, - ) - - # Update with GitHub data - db.pull_requests.update_pr_github_data( - pr_id=pr_id, - pr_number=pr_details.number, - pr_url=pr_details.url, - github_created_at=pr_details.created_at, - ) - - # Broadcast PR created event - await broadcast_pr_created( - manager=manager, - project_id=project_id, - pr_id=pr_id, - pr_number=pr_details.number, - pr_url=pr_details.url, - title=request.title, - branch_name=request.branch, - ) - - logger.info(f"Created PR #{pr_details.number} for project {project_id}") - - return CreatePRResponse( - pr_id=pr_id, - pr_number=pr_details.number, - pr_url=pr_details.url, - status="open", - ) - - except GitHubAPIError as e: - logger.error(f"GitHub API error creating PR: {e}") - raise HTTPException(status_code=422, detail=str(e)) - - finally: - if gh is not None: - await gh.close() - - -@router.get("", response_model=PRListResponse) -async def list_pull_requests( - project_id: int, - status: Optional[str] = None, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """List pull requests for a project. - - Args: - project_id: Project ID - status: Optional filter by status (open, merged, closed, draft) - - Returns: - List of PRs with total count - """ - await validate_project_access(project_id, db, current_user) - - prs = db.pull_requests.list_prs(project_id, status=status) - - return PRListResponse(prs=prs, total=len(prs)) - - -@router.get("/{pr_number}") -async def get_pull_request( - project_id: int, - pr_number: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get pull request details by PR number. - - Args: - project_id: Project ID - pr_number: GitHub PR number - - Returns: - PR details - - Raises: - HTTPException: 404 if PR not found - """ - await validate_project_access(project_id, db, current_user) - - pr = db.pull_requests.get_pr_by_number(project_id, pr_number) - if not pr: - raise HTTPException(status_code=404, detail=f"PR #{pr_number} not found") - - return pr - - -@router.post("/{pr_number}/merge", response_model=MergePRResponse) -async def merge_pull_request( - project_id: int, - pr_number: int, - request: MergePRRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Merge a pull request via GitHub API. - - Args: - project_id: Project ID - pr_number: GitHub PR number to merge - request: Merge request with method (squash, merge, rebase) - - Returns: - Merge result with SHA - - Raises: - HTTPException: - - 404: PR not found - - 422: GitHub API error (not mergeable, conflicts, etc.) - """ - await validate_project_access(project_id, db, current_user) - - # Verify PR exists in our database - pr = db.pull_requests.get_pr_by_number(project_id, pr_number) - if not pr: - raise HTTPException(status_code=404, detail=f"PR #{pr_number} not found") - - # Get GitHub config - config = get_global_config() - github_token, github_repo = validate_github_config(config) - - # Merge via GitHub API - gh: Optional[GitHubIntegration] = None - try: - gh = GitHubIntegration(token=github_token, repo=github_repo) - result = await gh.merge_pull_request( - pr_number=pr_number, - method=request.method, - ) - - # Update database only if merge succeeded - if result.merged: - db.pull_requests.update_pr_status( - pr_id=pr["id"], - status="merged", - merge_commit_sha=result.sha, - ) - - # Broadcast PR merged event - if result.sha: - await broadcast_pr_merged( - manager=manager, - project_id=project_id, - pr_number=pr_number, - merge_commit_sha=result.sha, - ) - - logger.info(f"Merged PR #{pr_number} for project {project_id}") - else: - logger.warning(f"PR #{pr_number} merge returned merged=False") - - return MergePRResponse( - merged=result.merged, - merge_commit_sha=result.sha, - ) - - except GitHubAPIError as e: - logger.error(f"GitHub API error merging PR: {e}") - raise HTTPException(status_code=422, detail=str(e)) - - finally: - if gh is not None: - await gh.close() - - -@router.post("/{pr_number}/close", response_model=ClosePRResponse) -async def close_pull_request( - project_id: int, - pr_number: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Close a pull request without merging. - - Args: - project_id: Project ID - pr_number: GitHub PR number to close - - Returns: - Close result - - Raises: - HTTPException: 404 if PR not found - """ - await validate_project_access(project_id, db, current_user) - - # Verify PR exists in our database - pr = db.pull_requests.get_pr_by_number(project_id, pr_number) - if not pr: - raise HTTPException(status_code=404, detail=f"PR #{pr_number} not found") - - # Get GitHub config - config = get_global_config() - github_token, github_repo = validate_github_config(config) - - # Close via GitHub API - gh: Optional[GitHubIntegration] = None - try: - gh = GitHubIntegration(token=github_token, repo=github_repo) - closed = await gh.close_pull_request(pr_number) - - # Update database only if close succeeded - if closed: - db.pull_requests.update_pr_status( - pr_id=pr["id"], - status="closed", - ) - - # Broadcast PR closed event - await broadcast_pr_closed( - manager=manager, - project_id=project_id, - pr_number=pr_number, - ) - - logger.info(f"Closed PR #{pr_number} for project {project_id}") - else: - logger.warning(f"PR #{pr_number} close returned closed=False") - - return ClosePRResponse(closed=closed) - - except GitHubAPIError as e: - logger.error(f"GitHub API error closing PR: {e}") - raise HTTPException(status_code=422, detail=str(e)) - - finally: - if gh is not None: - await gh.close() diff --git a/codeframe/ui/routers/quality_gates.py b/codeframe/ui/routers/quality_gates.py deleted file mode 100644 index 7688b8b0..00000000 --- a/codeframe/ui/routers/quality_gates.py +++ /dev/null @@ -1,344 +0,0 @@ -"""Quality gates router for CodeFRAME FastAPI server. - -Handles quality gate endpoints including triggering quality gate execution, -retrieving quality gate status, and managing task quality validations. - -Sprint 10 Phase 3 endpoints: -- POST /api/tasks/{task_id}/quality-gates - Manually trigger quality gates -- GET /api/tasks/{task_id}/quality-gates - Get quality gate status -""" - -from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends -from datetime import datetime, UTC, timezone -import logging -import uuid - -from codeframe.ui.models import QualityGatesRequest -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager -from codeframe.persistence.database import Database -from codeframe.lib.quality_gates import QualityGates -from codeframe.core.models import ( - QualityGateResult, - QualityGateFailure, - QualityGateType, - Severity, -) -from pathlib import Path - -# Module logger -logger = logging.getLogger(__name__) - -# Create router without prefix since endpoints use full path -router = APIRouter(tags=["quality-gates"]) - - -@router.get("/api/tasks/{task_id}/quality-gates") -async def get_quality_gate_status( - task_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get quality gate status for a task (T064). - - Sprint 10 - Phase 3: Quality Gates API - - Returns the quality gate status for a specific task, including which gates - passed/failed and detailed failure information. - - Args: - task_id: Task ID to get quality gate status for - db: Database connection (injected) - - Returns: - 200 OK: Quality gate status - { - "task_id": int, - "status": str, # 'pending', 'running', 'passed', 'failed', or None - "failures": [ - { - "gate": str, # 'tests', 'type_check', 'coverage', 'code_review', 'linting' - "reason": str, # Short failure reason - "details": str | null, # Detailed output - "severity": str # 'critical', 'high', 'medium', 'low' - }, - ... - ], - "requires_human_approval": bool, - "timestamp": str # ISO timestamp - } - - 404 Not Found: Task not found - - Example: - GET /api/tasks/42/quality-gates - """ - # Check if task exists - task = db.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - # Authorization check - get project_id from task - project_id = task.project_id - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get quality gate status from database - status_data = db.get_quality_gate_status(task_id) - - # Add task_id and timestamp to response - return { - "task_id": task_id, - "status": status_data.get("status"), - "failures": status_data.get("failures", []), - "requires_human_approval": status_data.get("requires_human_approval", False), - "timestamp": datetime.now(UTC).isoformat(), - } - - -@router.post("/api/tasks/{task_id}/quality-gates", status_code=202) -async def trigger_quality_gates( - task_id: int, - background_tasks: BackgroundTasks, - request: QualityGatesRequest = QualityGatesRequest(), - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Manually trigger quality gates for a task (T065). - - Sprint 10 - Phase 3: Quality Gates API - - Triggers quality gate execution for a specific task. Runs in background and - returns immediately with job status. Optionally accepts gate_types to run - specific gates only. - - Args: - task_id: Task ID to run quality gates for - background_tasks: FastAPI background tasks - request: QualityGatesRequest with optional gate_types list - Valid gate types: 'tests', 'type_check', 'coverage', 'code_review', 'linting' - db: Database connection (injected) - - Returns: - 202 Accepted: Quality gates job started - { - "job_id": str, - "task_id": int, - "status": "running", - "gate_types": list[str], # Gates being executed - "message": str - } - - 400 Bad Request: Invalid gate_types - 404 Not Found: Task not found - 500 Internal Server Error: Missing project workspace or API configuration - - Example: - POST /api/tasks/42/quality-gates - Body: { - "gate_types": ["tests", "coverage"] # Optional - } - """ - # Extract gate_types from request - gate_types = request.gate_types - - # Validate gate_types if provided - valid_gate_types = [ - "tests", - "type_check", - "coverage", - "code_review", - "linting", - ] - if gate_types: - invalid_gates = [g for g in gate_types if g not in valid_gate_types] - if invalid_gates: - raise HTTPException( - status_code=400, - detail=f"Invalid gate types: {invalid_gates}. Valid types: {valid_gate_types}", - ) - - # Check if task exists - task_data = db.get_task(task_id) - if not task_data: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - # Get project_id from task - project_id = task_data.project_id - if not project_id: - raise HTTPException(status_code=500, detail=f"Task {task_id} has no project_id") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get project workspace path - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=500, detail=f"Project {project_id} not found") - - workspace_path = project.get("workspace_path") - if not workspace_path: - raise HTTPException( - status_code=500, - detail=f"Project {project_id} has no workspace path configured", - ) - - # Generate job ID - job_id = str(uuid.uuid4()) - - # Build Task object for quality gates - task_data is already a Task object - task = task_data - - # Determine which gates to run - gates_to_run = gate_types if gate_types else ["all"] - - # Capture db_path for background task (don't capture request-scoped db instance) - db_path = db.db_path - - # Background task to run quality gates - async def run_quality_gates(): - """Background task to execute quality gates with fresh DB connection.""" - # Create fresh database connection for background task - task_db = Database(db_path) - task_db.initialize() - - try: - logger.info( - f"Quality gates job {job_id} started for task {task_id}, " f"gates={gates_to_run}" - ) - - # Update task status to 'running' - task_db.update_quality_gate_status(task_id=task_id, status="running", failures=[]) - - # Broadcast quality_gates_started event - try: - await manager.broadcast( - { - "type": "quality_gates_started", - "task_id": task_id, - "project_id": project_id, - "job_id": job_id, - "gate_types": gates_to_run, - "timestamp": datetime.now(UTC).isoformat(), - } - ) - except Exception as e: - logger.warning(f"Failed to broadcast quality_gates_started: {e}") - - # Create QualityGates instance - quality_gates = QualityGates( - db=task_db, - project_id=project_id, - project_root=Path(workspace_path), - ) - - # Run gates based on gate_types - if not gate_types or "all" in gates_to_run: - # Run all gates - result = await quality_gates.run_all_gates(task) - else: - # Run specific gates - all_failures = [] - execution_start = datetime.now(timezone.utc) - - gate_method_map = { - "tests": quality_gates.run_tests_gate, - "type_check": quality_gates.run_type_check_gate, - "coverage": quality_gates.run_coverage_gate, - "code_review": quality_gates.run_review_gate, - "linting": quality_gates.run_linting_gate, - } - - for gate_type in gate_types: - gate_method = gate_method_map.get(gate_type) - if gate_method: - gate_result = await gate_method(task) - all_failures.extend(gate_result.failures) - - execution_time = (datetime.now(timezone.utc) - execution_start).total_seconds() - status = "passed" if len(all_failures) == 0 else "failed" - - result = QualityGateResult( - task_id=task_id, - status=status, - failures=all_failures, - execution_time_seconds=execution_time, - ) - - # Update database with final result - task_db.update_quality_gate_status( - task_id=task_id, status=status, failures=all_failures - ) - - # Broadcast completion event - try: - event_type = "quality_gates_passed" if result.passed else "quality_gates_failed" - await manager.broadcast( - { - "type": event_type, - "task_id": task_id, - "project_id": project_id, - "job_id": job_id, - "status": result.status, - "failures_count": len(result.failures), - "execution_time_seconds": result.execution_time_seconds, - "timestamp": datetime.now(UTC).isoformat(), - } - ) - except Exception as e: - logger.warning(f"Failed to broadcast quality_gates_completed: {e}") - - logger.info( - f"Quality gates job {job_id} completed: " - f"status={result.status}, failures={len(result.failures)}" - ) - - except Exception as e: - logger.error(f"Quality gates job {job_id} failed: {e}", exc_info=True) - - # Update status to 'failed' with error - error_failure = QualityGateFailure( - gate=QualityGateType.TESTS, # Generic gate type for errors - reason=f"Quality gates execution failed: {str(e)}", - details=str(e), - severity=Severity.CRITICAL, - ) - - task_db.update_quality_gate_status( - task_id=task_id, status="failed", failures=[error_failure] - ) - - # Broadcast failure event - try: - await manager.broadcast( - { - "type": "quality_gates_error", - "task_id": task_id, - "project_id": project_id, - "job_id": job_id, - "error": str(e), - "timestamp": datetime.now(UTC).isoformat(), - } - ) - except Exception as broadcast_error: - logger.warning(f"Failed to broadcast quality_gates_error: {broadcast_error}") - - finally: - # Always close the database connection - if task_db and task_db.conn: - task_db.close() - - # Add background task - background_tasks.add_task(run_quality_gates) - - # Return 202 Accepted immediately - return { - "job_id": job_id, - "task_id": task_id, - "status": "running", - "gate_types": gates_to_run, - "message": f"Quality gates execution started for task {task_id}", - } diff --git a/codeframe/ui/routers/review.py b/codeframe/ui/routers/review.py deleted file mode 100644 index aecd39f8..00000000 --- a/codeframe/ui/routers/review.py +++ /dev/null @@ -1,785 +0,0 @@ -"""Review router for CodeFRAME FastAPI server. - -Handles code review endpoints including triggering reviews, retrieving review -results, and managing review statistics. - -Sprint 9 & Sprint 10 endpoints: -- POST /api/agents/{agent_id}/review - Trigger code review for a task -- GET /api/tasks/{task_id}/review-status - Get review status for a task -- GET /api/projects/{project_id}/review-stats - Get aggregated review statistics -- POST /api/agents/review/analyze - Trigger code review analysis (background) -- GET /api/tasks/{task_id}/reviews - Get code review findings for a task -- GET /api/projects/{project_id}/code-reviews - Get project-level code reviews -""" - -from fastapi import APIRouter, HTTPException, BackgroundTasks, Request, Depends -from typing import Optional -from datetime import datetime, UTC -import logging -import uuid - -from codeframe.ui.models import ReviewRequest -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.ui.shared import manager, review_cache -from codeframe.persistence.database import Database -from codeframe.agents.review_worker_agent import ReviewWorkerAgent -from codeframe.agents.review_agent import ReviewAgent -from codeframe.core.models import Task - -# Module logger -logger = logging.getLogger(__name__) - -# Create router without prefix since review endpoints have mixed prefixes -# (/api/agents, /api/tasks, /api/projects) -router = APIRouter(tags=["review"]) - - -@router.post("/api/agents/{agent_id}/review") -async def trigger_review( - agent_id: str, - request: ReviewRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Trigger code review for a task (T056). - - Sprint 9 - User Story 1: Review Agent API - - Executes code review using ReviewWorkerAgent and returns review report - with findings, scores, and recommendations. - - Args: - agent_id: Review agent ID to use - request: ReviewRequest with task_id, project_id, files_modified - db: Database connection (injected) - - Returns: - 200 OK: ReviewReport with status, overall_score, findings - 500 Internal Server Error: Review execution failed - - Example: - POST /api/agents/review-001/review - Body: { - "task_id": 42, - "project_id": 123, - "files_modified": ["/path/to/file.py"] - } - - Response: { - "status": "approved", - "overall_score": 85.5, - "findings": [ - { - "category": "complexity", - "severity": "medium", - "message": "Function has complexity of 12", - "file_path": "/path/to/file.py", - "line_number": 42, - "suggestion": "Consider breaking into smaller functions" - } - ], - "reviewer_agent_id": "review-001", - "task_id": 42 - } - """ - try: - # Verify project exists and user has access - project = db.get_project(request.project_id) - if not project: - raise HTTPException( - status_code=404, detail=f"Project {request.project_id} not found" - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, request.project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Emit review started event (T059) - await manager.broadcast( - { - "type": "review_started", - "agent_id": agent_id, - "project_id": request.project_id, - "task_id": request.task_id, - "timestamp": datetime.now(UTC).isoformat(), - } - ) - - # Create review agent - review_agent = ReviewWorkerAgent(agent_id=agent_id, db=db) - - # Get task data from database - task_data = db.get_task(request.task_id) - if not task_data: - raise HTTPException(status_code=404, detail=f"Task {request.task_id} not found") - - # Build task dict for execute_task - task = { - "id": request.task_id, - "task_number": task_data.task_number or "unknown", - "title": task_data.title or "", - "description": task_data.description or "", - "files_modified": request.files_modified, - "project_id": task_data.project_id, - } - - # Execute review - report = await review_agent.execute_task(task) - - if not report: - raise HTTPException(status_code=500, detail="Review failed to produce report") - - # Cache the review report for later retrieval (T057, T058) - report_dict = report.model_dump() - report_dict["project_id"] = request.project_id # Add project_id for filtering - review_cache[request.task_id] = report_dict - - # Emit WebSocket event based on review status (T059) - event_type_map = { - "approved": "review_approved", - "changes_requested": "review_changes_requested", - "rejected": "review_rejected", - } - event_type = event_type_map.get(report.status, "review_completed") - - await manager.broadcast( - { - "type": event_type, - "agent_id": agent_id, - "project_id": request.project_id, - "task_id": request.task_id, - "status": report.status, - "overall_score": report.overall_score, - "findings_count": len(report.findings), - "timestamp": datetime.now(UTC).isoformat(), - } - ) - - # Return report as dict - return report_dict - - except HTTPException: - raise - except Exception as e: - # Emit failure event - await manager.broadcast( - { - "type": "review_failed", - "agent_id": agent_id, - "project_id": request.project_id, - "task_id": request.task_id, - "error": str(e), - "timestamp": datetime.now(UTC).isoformat(), - } - ) - raise HTTPException(status_code=500, detail=f"Review execution failed: {str(e)}") - - -@router.get("/api/tasks/{task_id}/review-status") -async def get_review_status( - task_id: int, db: Database = Depends(get_db), current_user: User = Depends(get_current_user) -): - """Get review status for a task (T057). - - Returns the cached review report if available, otherwise indicates no review exists. - - Args: - task_id: Task ID to get review status for - - Returns: - 200 OK: Review status object - - Example: - GET /api/tasks/123/review-status - Response: { - "has_review": true, - "status": "approved", - "overall_score": 85.5, - "findings_count": 3 - } - """ - try: - # Get task to obtain project_id for authorization - task = db.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - project_id = task.project_id - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Check if review exists in cache - if task_id in review_cache: - report = review_cache[task_id] - return { - "has_review": True, - "status": report["status"], - "overall_score": report["overall_score"], - "findings_count": len(report.get("findings", [])), - } - else: - # No review exists yet - return { - "has_review": False, - "status": None, - "overall_score": None, - "findings_count": 0, - } - except HTTPException: - # Re-raise HTTPException (including 403 Forbidden) without masking - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get review status: {str(e)}") - - -@router.get("/api/projects/{project_id}/review-stats") -async def get_review_stats( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get aggregated review statistics for a project (T058). - - Returns counts and averages for all reviews in the project. - - Args: - project_id: Project ID to get review stats for - - Returns: - 200 OK: Review statistics object - - Example: - GET /api/projects/123/review-stats - Response: { - "total_reviews": 5, - "approved_count": 3, - "changes_requested_count": 1, - "rejected_count": 1, - "average_score": 75.5 - } - """ - try: - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Filter reviews for this project - project_reviews = [ - report for report in review_cache.values() if report.get("project_id") == project_id - ] - - # Calculate stats - total_reviews = len(project_reviews) - - if total_reviews == 0: - return { - "total_reviews": 0, - "approved_count": 0, - "changes_requested_count": 0, - "rejected_count": 0, - "average_score": 0.0, - } - - # Count by status - approved_count = sum(1 for r in project_reviews if r.get("status") == "approved") - changes_requested_count = sum( - 1 for r in project_reviews if r.get("status") == "changes_requested" - ) - rejected_count = sum(1 for r in project_reviews if r.get("status") == "rejected") - - # Calculate average score - total_score = sum(r.get("overall_score", 0) for r in project_reviews) - average_score = round(total_score / total_reviews, 1) if total_reviews > 0 else 0.0 - - return { - "total_reviews": total_reviews, - "approved_count": approved_count, - "changes_requested_count": changes_requested_count, - "rejected_count": rejected_count, - "average_score": average_score, - } - - except HTTPException: - # Re-raise HTTPException (including 403 Forbidden) without masking - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get review stats: {str(e)}") - - -# Sprint 10 Phase 2: Review Agent API endpoints (T034, T035) - - -@router.post("/api/agents/review/analyze", status_code=202) -async def analyze_code_review( - request: Request, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Trigger code review analysis for a task (T034). - - Sprint 10 - Phase 2: Review Agent API - - Accepts a task_id and optional project_id, creates a ReviewAgent instance, - and executes the review in a background task. Returns immediately with job status. - - Args: - request: FastAPI request containing: - - task_id: int (required) - Task ID to review - - project_id: int (optional) - Project ID for scoping - background_tasks: FastAPI background tasks - db: Database connection (injected) - - Returns: - 202 Accepted: Review job started - { - "job_id": str, - "status": "started", - "message": "Code review analysis started for task {task_id}" - } - - 400 Bad Request: Invalid request (missing task_id) - 404 Not Found: Task not found - - Example: - POST /api/agents/review/analyze - Body: { - "task_id": 42, - "project_id": 123 - } - """ - try: - # Parse request body - data = await request.json() - task_id = data.get("task_id") - project_id = data.get("project_id") - - # Validate task_id - if not task_id: - raise HTTPException(status_code=400, detail="task_id is required") - - # Check if task exists - task_data = db.get_task(task_id) - if not task_data: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - # Use project_id from request or task data - if not project_id: - project_id = task_data.project_id - - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Generate job ID - job_id = str(uuid.uuid4()) - - # Capture app reference for background task (db is request-scoped) - app = request.app - - # Create background task to run review - async def run_review(): - """Background task to execute code review.""" - try: - # Get database connection from app state (not request-scoped) - task_db = app.state.db - - # Create ReviewAgent instance - review_agent = ReviewAgent( - agent_id=f"review-{job_id[:8]}", - db=task_db, - project_id=project_id, - ws_manager=manager, - ) - - # Build Task object from task_data - task = Task( - id=task_id, - title=task_data.title or "", - description=task_data.description or "", - project_id=project_id, - status=task_data.status, - priority=task_data.priority, - ) - - # Execute review (this saves findings to database) - result = await review_agent.execute_task(task) - - logger.info( - f"Review job {job_id} completed: {result.status}, " - f"{len(result.findings)} findings" - ) - - except Exception as e: - logger.error(f"Review job {job_id} failed: {e}", exc_info=True) - - # Add background task - background_tasks.add_task(run_review) - - # Return 202 Accepted immediately - return { - "job_id": job_id, - "status": "started", - "message": f"Code review analysis started for task {task_id}", - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to start code review: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to start review: {str(e)}") - - -def _extract_enum_value_for_counting(obj, attr_name: str): - """Extract enum value or string for counting logic. - - Returns None if attribute is missing or None (to skip counting), - otherwise returns the string value or enum.value. - - Args: - obj: Object to extract attribute from - attr_name: Name of the attribute (e.g., 'severity', 'category') - - Returns: - str | None: The extracted value or None - """ - if not hasattr(obj, attr_name): - return None - - attr = getattr(obj, attr_name) - if attr is None: - return None - - # Check if it's an enum with .value - if hasattr(attr, "value"): - return attr.value - - # Otherwise convert to string - return str(attr) - - -def _extract_enum_value(obj, attr_name: str, default: str): - """Extract enum value or string with default fallback. - - Returns default when attribute is missing or None, - otherwise returns the string value or enum.value. - - Args: - obj: Object to extract attribute from - attr_name: Name of the attribute (e.g., 'severity', 'category') - default: Default value to return if attribute is missing/None - - Returns: - str: The extracted value or default - """ - if not hasattr(obj, attr_name): - return default - - attr = getattr(obj, attr_name) - if attr is None: - return default - - # Check if it's an enum with .value - if hasattr(attr, "value"): - return attr.value - - # Otherwise convert to string - return str(attr) - - -@router.get("/api/tasks/{task_id}/reviews") -async def get_task_reviews( - task_id: int, - severity: Optional[str] = None, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get code review findings for a task (T035). - - Sprint 10 - Phase 2: Review Agent API - - Returns all code review findings for a specific task, optionally filtered by severity. - Includes summary statistics (total findings, counts by severity, blocking status). - - Args: - task_id: Task ID to get reviews for - severity: Optional severity filter (critical, high, medium, low, info) - db: Database connection (injected) - - Returns: - 200 OK: Review findings with summary statistics (matches ReviewResult interface) - { - "task_id": int, - "findings": [ - { - "id": int, - "task_id": int, - "agent_id": str, - "project_id": int, - "file_path": str, - "line_number": int | null, - "severity": str, - "category": str, - "message": str, - "recommendation": str | null, - "code_snippet": str | null, - "created_at": str - }, - ... - ], - "total_count": int, - "severity_counts": { - "critical": int, - "high": int, - "medium": int, - "low": int, - "info": int - }, - "category_counts": { - "security": int, - "performance": int, - "quality": int, - "maintainability": int, - "style": int - }, - "has_blocking_findings": bool - } - - 400 Bad Request: Invalid severity value - 404 Not Found: Task not found - - Example: - GET /api/tasks/42/reviews - GET /api/tasks/42/reviews?severity=critical - """ - # Validate severity if provided - valid_severities = ["critical", "high", "medium", "low", "info"] - if severity and severity not in valid_severities: - raise HTTPException( - status_code=400, - detail=f"Invalid severity. Must be one of: {', '.join(valid_severities)}", - ) - - # Check if task exists - task = db.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - - # Authorization check - get project_id from task - project_id = task.project_id - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get code reviews from database - reviews = db.get_code_reviews(task_id=task_id, severity=severity) - - # Build summary statistics - severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} - - category_counts = { - "security": 0, - "performance": 0, - "quality": 0, - "maintainability": 0, - "style": 0, - } - - for review in reviews: - # Extract severity and category using helper (returns None if missing/invalid) - severity_val = _extract_enum_value_for_counting(review, "severity") - if severity_val and severity_val in severity_counts: - severity_counts[severity_val] += 1 - - category_val = _extract_enum_value_for_counting(review, "category") - if category_val and category_val in category_counts: - category_counts[category_val] += 1 - - # Blocking issues are critical or high severity - has_blocking_findings = (severity_counts["critical"] + severity_counts["high"]) > 0 - - # Convert CodeReview objects to dictionaries - findings_data = [] - for review in reviews: - # Extract severity and category using helper (returns default if missing/invalid) - severity_val = _extract_enum_value(review, "severity", "unknown") - category_val = _extract_enum_value(review, "category", "unknown") - - findings_data.append( - { - "id": review.id, - "task_id": review.task_id, - "agent_id": review.agent_id, - "project_id": review.project_id, - "file_path": review.file_path, - "line_number": review.line_number, - "severity": severity_val, - "category": category_val, - "message": review.message, - "recommendation": review.recommendation, - "code_snippet": review.code_snippet, - "created_at": review.created_at, - } - ) - - # Build response matching ReviewResult interface - return { - "task_id": task_id, - "findings": findings_data, - "total_count": len(reviews), - "severity_counts": severity_counts, - "category_counts": category_counts, - "has_blocking_findings": has_blocking_findings, - } - - -@router.get("/api/projects/{project_id}/code-reviews") -async def get_project_code_reviews( - project_id: int, - severity: Optional[str] = None, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Get aggregated code review findings for all tasks in a project. - - Returns all code review findings across all tasks in the project, - optionally filtered by severity. Includes summary statistics aggregated - at the project level. - - Args: - project_id: Project ID to fetch reviews for - severity: Optional severity filter (critical, high, medium, low, info) - db: Database connection (injected) - - Returns: - 200 OK: Review findings with project-level summary statistics - { - "findings": [ - { - "id": int, - "task_id": int, - "agent_id": str, - "project_id": int, - "file_path": str, - "line_number": int | null, - "severity": str, - "category": str, - "message": str, - "recommendation": str | null, - "code_snippet": str | null, - "created_at": str - }, - ... - ], - "summary": { - "total_findings": int, - "by_severity": { - "critical": int, - "high": int, - "medium": int, - "low": int, - "info": int - }, - "by_category": { - "security": int, - "performance": int, - "quality": int, - "maintainability": int, - "style": int - }, - "has_blocking_issues": bool - }, - "task_id": null - } - - 400 Bad Request: Invalid severity value - 404 Not Found: Project not found - - Example: - GET /api/projects/2/code-reviews - GET /api/projects/2/code-reviews?severity=critical - """ - # Validate severity if provided - valid_severities = ["critical", "high", "medium", "low", "info"] - if severity and severity not in valid_severities: - raise HTTPException( - status_code=400, - detail=f"Invalid severity. Must be one of: {', '.join(valid_severities)}", - ) - - # Check if project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Get code reviews from database - reviews = db.get_code_reviews_by_project(project_id=project_id, severity=severity) - - # Build summary statistics - by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} - - by_category = {"security": 0, "performance": 0, "quality": 0, "maintainability": 0, "style": 0} - - for review in reviews: - # Extract severity and category using helper (returns None if missing/invalid) - severity_val = _extract_enum_value_for_counting(review, "severity") - if severity_val and severity_val in by_severity: - by_severity[severity_val] += 1 - - category_val = _extract_enum_value_for_counting(review, "category") - if category_val and category_val in by_category: - by_category[category_val] += 1 - - # Blocking issues are critical or high severity - has_blocking_issues = (by_severity["critical"] + by_severity["high"]) > 0 - - # Convert CodeReview objects to dictionaries - findings_data = [] - for review in reviews: - # Extract severity and category using helper (returns default if missing/invalid) - severity_val = _extract_enum_value(review, "severity", "unknown") - category_val = _extract_enum_value(review, "category", "unknown") - - findings_data.append( - { - "id": review.id, - "task_id": review.task_id, - "agent_id": review.agent_id, - "project_id": review.project_id, - "file_path": review.file_path, - "line_number": review.line_number, - "severity": severity_val, - "category": category_val, - "message": review.message, - "recommendation": review.recommendation, - "code_snippet": review.code_snippet, - "created_at": review.created_at, - } - ) - - # Build response (matches get_task_reviews flat structure) - return { - "findings": findings_data, - "total_count": len(reviews), - "severity_counts": by_severity, - "category_counts": by_category, - "has_blocking_findings": has_blocking_issues, - "task_id": None, # Project-level aggregate - } diff --git a/codeframe/ui/routers/schedule.py b/codeframe/ui/routers/schedule.py deleted file mode 100644 index 0c662337..00000000 --- a/codeframe/ui/routers/schedule.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Schedule management router. - -This module provides API endpoints for: -- Viewing project schedules -- Predicting completion dates -- Identifying scheduling bottlenecks -""" - -import logging -from datetime import datetime -from typing import Any, Dict, List, Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.planning.task_scheduler import TaskScheduler -from codeframe.agents.dependency_resolver import DependencyResolver - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/schedule", tags=["schedule"]) - - -# ============================================================================ -# Response Models -# ============================================================================ - - -class TaskAssignmentResponse(BaseModel): - """Response model for a single task assignment.""" - - task_id: int - start_time: float - end_time: float - assigned_agent: Optional[int] = None - - -class ScheduleResponse(BaseModel): - """Response model for project schedule.""" - - task_assignments: Dict[int, TaskAssignmentResponse] - total_duration: float - agents_used: int - - -class CompletionPredictionResponse(BaseModel): - """Response model for completion prediction.""" - - predicted_date: str - remaining_hours: float - completed_percentage: float - confidence_interval: Dict[str, str] - - -class BottleneckResponse(BaseModel): - """Response model for a scheduling bottleneck.""" - - task_id: int - task_title: str - bottleneck_type: str - impact_hours: float - recommendation: str - - -# ============================================================================ -# Schedule Endpoints -# ============================================================================ - - -@router.get("/{project_id}", response_model=ScheduleResponse) -async def get_project_schedule( - project_id: int, - agents: int = Query(1, ge=1, description="Number of parallel agents/workers"), - db: Database = Depends(get_db), -) -> Dict[str, Any]: - """Get the schedule for a project. - - Args: - project_id: Project ID - agents: Number of parallel agents/workers (default: 1, must be >= 1) - db: Database connection - - Returns: - Project schedule with task assignments - """ - try: - tasks = db.get_project_tasks(project_id) - if not tasks: - raise HTTPException(status_code=404, detail="No tasks found for project") - - # Build dependency graph and schedule - resolver = DependencyResolver() - resolver.build_dependency_graph(tasks) - - scheduler = TaskScheduler() - - # Extract durations - task_durations = {} - for task in tasks: - duration = getattr(task, "estimated_hours", None) - if duration is None or duration <= 0: - duration = 1.0 - task_durations[task.id] = duration - - schedule = scheduler.schedule_tasks( - tasks=tasks, - task_durations=task_durations, - resolver=resolver, - agents_available=agents, - ) - - # Convert to response format - assignments = {} - for task_id, assignment in schedule.task_assignments.items(): - assignments[task_id] = { - "task_id": task_id, - "start_time": assignment.start_time, - "end_time": assignment.end_time, - "assigned_agent": assignment.assigned_agent, - } - - return { - "task_assignments": assignments, - "total_duration": schedule.total_duration, - "agents_used": schedule.agents_used, - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting schedule for project {project_id}: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get("/{project_id}/predict", response_model=CompletionPredictionResponse) -async def predict_completion( - project_id: int, - hours_per_day: float = Query(8.0, gt=0, le=24, description="Working hours per day"), - db: Database = Depends(get_db), -) -> Dict[str, Any]: - """Predict project completion date. - - Args: - project_id: Project ID - hours_per_day: Working hours per day (default: 8, must be > 0 and <= 24) - db: Database connection - - Returns: - Completion prediction with confidence interval - """ - try: - tasks = db.get_project_tasks(project_id) - if not tasks: - raise HTTPException(status_code=404, detail="No tasks found for project") - - # Build schedule - resolver = DependencyResolver() - resolver.build_dependency_graph(tasks) - scheduler = TaskScheduler() - - task_durations = {} - for task in tasks: - val = getattr(task, "estimated_hours", None) - duration = val if val is not None and val > 0 else 1.0 - task_durations[task.id] = duration - - schedule = scheduler.schedule_tasks( - tasks=tasks, - task_durations=task_durations, - resolver=resolver, - agents_available=1, - ) - - # Get current progress - current_progress = {} - for task in tasks: - status = task.status.value if hasattr(task.status, "value") else str(task.status) - if status.upper() in ("DONE", "COMPLETED"): - current_progress[task.id] = "completed" - - # Predict completion - prediction = scheduler.predict_completion_date( - schedule=schedule, - current_progress=current_progress, - start_date=datetime.now(), - hours_per_day=hours_per_day, - ) - - return { - "predicted_date": prediction.predicted_date.isoformat(), - "remaining_hours": prediction.remaining_hours, - "completed_percentage": prediction.completed_percentage, - "confidence_interval": { - "early": prediction.confidence_interval["early"].isoformat(), - "late": prediction.confidence_interval["late"].isoformat(), - }, - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error predicting completion for project {project_id}: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get("/{project_id}/bottlenecks", response_model=List[BottleneckResponse]) -async def get_bottlenecks( - project_id: int, - db: Database = Depends(get_db), -) -> List[Dict[str, Any]]: - """Identify scheduling bottlenecks for a project. - - Args: - project_id: Project ID - db: Database connection - - Returns: - List of identified bottlenecks with recommendations - """ - try: - tasks = db.get_project_tasks(project_id) - if not tasks: - raise HTTPException(status_code=404, detail="No tasks found for project") - - # Build schedule - resolver = DependencyResolver() - resolver.build_dependency_graph(tasks) - scheduler = TaskScheduler() - - task_durations = {} - for task in tasks: - val = getattr(task, "estimated_hours", None) - duration = val if val is not None and val > 0 else 1.0 - task_durations[task.id] = duration - - schedule = scheduler.schedule_tasks( - tasks=tasks, - task_durations=task_durations, - resolver=resolver, - agents_available=1, - ) - - bottlenecks = scheduler.identify_bottlenecks( - schedule=schedule, - task_durations=task_durations, - resolver=resolver, - ) - - # Create task lookup for titles - task_lookup = {t.id: t for t in tasks} - - result = [] - for bn in bottlenecks: - task = task_lookup.get(bn.task_id) - title = task.title if task else f"Task {bn.task_id}" - result.append({ - "task_id": bn.task_id, - "task_title": title, - "bottleneck_type": bn.bottleneck_type, - "impact_hours": bn.impact_hours, - "recommendation": bn.recommendation, - }) - - return result - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting bottlenecks for project {project_id}: {e}") - raise HTTPException(status_code=500, detail="Internal server error") diff --git a/codeframe/ui/routers/session.py b/codeframe/ui/routers/session.py deleted file mode 100644 index ef6851c4..00000000 --- a/codeframe/ui/routers/session.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Session lifecycle management router. - -This module handles session state management endpoints for projects, -allowing retrieval of current session state including last session summary, -next actions, progress, and active blockers. -""" - -from datetime import datetime, UTC -from typing import Dict, Any - -from fastapi import APIRouter, HTTPException, Depends - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.core.session_manager import SessionManager -from codeframe.core.phase_manager import PHASE_STEPS - -router = APIRouter(prefix="/api/projects", tags=["session"]) - - -def _get_step_info(phase: str) -> Dict[str, Any]: - """Get step information for a phase. - - Args: - phase: Current project phase - - Returns: - Dict with current, total, and description fields - """ - step_config = PHASE_STEPS.get(phase, {"total": 1, "description": "Unknown"}) - return { - "current": 1, # Default to step 1; future enhancement can track actual progress - "total": step_config["total"], - "description": step_config["description"], - } - - -@router.get("/{project_id}/session") -async def get_session_state( - project_id: int, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> Dict[str, Any]: - """Get current session state for project (T028). - - Args: - project_id: Project ID - db: Database instance (injected) - - Returns: - Session state with last session summary, next actions, progress, blockers - Returns empty state if no session file exists - - Raises: - HTTPException: - - 404: Project not found - - Example: - GET /api/projects/1/session - """ - # Get project - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, detail={"error": "Project not found", "project_id": project_id} - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Extract phase information - current_phase = project.get("phase", "discovery") - - # Get project path - workspace_path = project.get("workspace_path") - if not workspace_path: - # Return empty state if no workspace path - return { - "last_session": { - "summary": "No previous session", - "timestamp": datetime.now(UTC).isoformat(), - }, - "next_actions": [], - "progress_pct": 0.0, - "active_blockers": [], - "phase": current_phase, - "step": _get_step_info(current_phase), - } - - # Load session state - session_mgr = SessionManager(workspace_path) - session = session_mgr.load_session() - - if not session: - # Return empty state - return { - "last_session": { - "summary": "No previous session", - "timestamp": datetime.now(UTC).isoformat(), - }, - "next_actions": [], - "progress_pct": 0.0, - "active_blockers": [], - "phase": current_phase, - "step": _get_step_info(current_phase), - } - - # Return session state (omit completed_tasks and current_plan for API response) - return { - "last_session": { - "summary": session["last_session"]["summary"], - "timestamp": session["last_session"]["timestamp"], - }, - "next_actions": session.get("next_actions", []), - "progress_pct": session.get("progress_pct", 0.0), - "active_blockers": session.get("active_blockers", []), - "phase": current_phase, - "step": _get_step_info(current_phase), - } diff --git a/codeframe/ui/routers/tasks.py b/codeframe/ui/routers/tasks.py deleted file mode 100644 index bf724367..00000000 --- a/codeframe/ui/routers/tasks.py +++ /dev/null @@ -1,638 +0,0 @@ -"""Task management router. - -This module provides API endpoints for: -- Task creation -- Task updates -- Task status management -- Task approval (for planning phase automation) -- Multi-agent execution trigger (P0 fix) -""" - -import asyncio -import logging -import os -from datetime import datetime, UTC -from typing import Any, List, Optional - -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request -from pydantic import BaseModel, Field, ConfigDict - -from codeframe.core.models import Task, TaskStatus -from codeframe.core.phase_manager import ( - PhaseManager, - ProjectNotFoundError, - InvalidPhaseTransitionError, -) -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.ui.shared import manager -from codeframe.ui.websocket_broadcasts import broadcast_development_started -from codeframe.auth.dependencies import get_current_user -from codeframe.auth.models import User -from codeframe.agents.lead_agent import LeadAgent -from codeframe.lib.rate_limiter import rate_limit_standard - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/tasks", tags=["tasks"]) - -# Also register under project-scoped prefix for task approval -project_router = APIRouter(prefix="/api/projects", tags=["tasks"]) - - -# ============================================================================ -# Background Task for Multi-Agent Execution (P0 Fix) -# ============================================================================ - - -async def start_development_execution( - project_id: int, - db: Database, - ws_manager: Any, - api_key: str -) -> None: - """ - Background task to start multi-agent execution after task approval. - - This function: - 1. Creates a LeadAgent instance for the project - 2. Calls start_multi_agent_execution() to create agents and assign tasks - 3. Handles errors gracefully with logging and WebSocket notifications - - Workflow: - - LeadAgent loads all approved tasks from database - - Builds dependency graph for task ordering - - Creates agents on-demand via AgentPoolManager - - Assigns tasks to agents and executes in parallel - - Broadcasts agent_created and task_assigned events via WebSocket - - Continues until all tasks complete or fail - - Args: - project_id: Project ID to start execution for - db: Database instance - ws_manager: WebSocket manager for broadcasts - api_key: Anthropic API key for agent creation - """ - try: - logger.info(f"🚀 Starting multi-agent execution for project {project_id}") - - # Create LeadAgent instance with WebSocket manager for event broadcasts - lead_agent = LeadAgent( - project_id=project_id, - db=db, - api_key=api_key, - ws_manager=ws_manager - ) - - # Start multi-agent execution (creates agents and assigns tasks) - # This is the main coordination loop that: - # 1. Loads all tasks and builds dependency graph - # 2. Creates agents on-demand via agent_pool_manager.get_or_create_agent() - # 3. Assigns tasks to agents and executes in parallel - # 4. Broadcasts agent_created events when agents are created - # 5. Broadcasts task_assigned events when tasks are assigned - # 6. Continues until all tasks complete or fail - summary = await lead_agent.start_multi_agent_execution( - max_retries=3, - max_concurrent=5, - timeout=300 - ) - - logger.info( - f"✅ Multi-agent execution completed for project {project_id}: " - f"{summary.get('completed', 0)}/{summary.get('total_tasks', 0)} tasks completed, " - f"{summary.get('failed', 0)} failed, {summary.get('execution_time', 0):.2f}s" - ) - - except asyncio.TimeoutError: - logger.error( - f"❌ Multi-agent execution timed out for project {project_id} after 300s" - ) - # Broadcast timeout error to UI (guarded to prevent masking original error) - try: - await ws_manager.broadcast( - { - "type": "development_failed", - "project_id": project_id, - "error": "Multi-agent execution timed out after 300 seconds", - "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z") - }, - project_id=project_id - ) - except Exception: - logger.exception("Failed to broadcast development_failed (timeout)") - except Exception as e: - logger.error( - f"❌ Failed to start multi-agent execution for project {project_id}: {e}", - exc_info=True - ) - # Broadcast error to UI (guarded to prevent masking original error) - try: - await ws_manager.broadcast( - { - "type": "development_failed", - "project_id": project_id, - "error": str(e), - "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z") - }, - project_id=project_id - ) - except Exception: - logger.exception("Failed to broadcast development_failed (error)") - - -class TaskCreateRequest(BaseModel): - """Request model for creating a task.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "project_id": 1, - "title": "Implement user authentication endpoint", - "description": "Create POST /api/auth/login endpoint with JWT token generation", - "priority": 2, - "status": "pending", - "workflow_step": 3, - "depends_on": "41,42", - "requires_mcp": False - } - } - ) - - project_id: int = Field(..., description="Project ID to create the task in") - title: str = Field( - ..., - min_length=1, - max_length=500, - description="Task title/summary (1-500 characters)" - ) - description: str = Field( - default="", - description="Detailed task description with requirements and acceptance criteria" - ) - priority: int = Field( - default=3, - ge=0, - le=4, - description="Task priority: 0=critical, 1=high, 2=medium, 3=low (default), 4=backlog" - ) - status: str = Field( - default="pending", - description="Initial task status. Usually 'pending' for new tasks." - ) - workflow_step: int = Field( - default=1, - ge=1, - description="Workflow step number for ordering tasks within a phase" - ) - depends_on: Optional[str] = Field( - default=None, - description="Comma-separated list of task IDs that must complete before this task can start" - ) - requires_mcp: bool = Field( - default=False, - description="Whether this task requires MCP (Model Context Protocol) server access" - ) - - -@router.post( - "", - status_code=201, - summary="Create a new task", - description="Creates a new task in the specified project. Tasks represent individual work items " - "that agents execute. Tasks can have dependencies on other tasks using the depends_on field.", - responses={ - 201: {"description": "Task created successfully"}, - 401: {"description": "Not authenticated"}, - 403: {"description": "Access denied - user doesn't have access to project"}, - 404: {"description": "Project not found"}, - 500: {"description": "Error creating task"}, - }, -) -@rate_limit_standard() -async def create_task( - request: Request, - body: TaskCreateRequest, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Create a new task. - - Args: - body: Task creation request - db: Database connection - current_user: Authenticated user - - Returns: - Created task details - - Raises: - HTTPException: - - 403: Access denied (user doesn't have access to project) - - 404: Project not found - """ - # Verify project exists - project = db.get_project(body.project_id) - if not project: - raise HTTPException( - status_code=404, - detail=f"Project {body.project_id} not found" - ) - - # Authorization check - user must have access to the project - if not db.user_has_project_access(current_user.id, body.project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Create task - try: - task = Task( - id=None, # Will be assigned by database - project_id=body.project_id, - title=body.title, - description=body.description, - status=TaskStatus(body.status), - priority=body.priority, - workflow_step=body.workflow_step, - depends_on=body.depends_on, - requires_mcp=body.requires_mcp, - ) - - task_id = db.create_task(task) - - # Fetch created task - created_task = db.get_task(task_id) - - return { - "id": created_task.id, - "project_id": created_task.project_id, - "title": created_task.title, - "description": created_task.description, - "status": created_task.status.value, - "priority": created_task.priority, - "workflow_step": created_task.workflow_step, - "depends_on": created_task.depends_on, - "requires_mcp": created_task.requires_mcp, - "created_at": created_task.created_at, - } - - except Exception as e: - logger.error(f"Error creating task: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Error creating task") - - -# ============================================================================ -# Task Approval Models and Endpoint (Feature: 016-planning-phase-automation) -# ============================================================================ - - -class TaskApprovalRequest(BaseModel): - """Request model for task approval.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "approved": True, - "excluded_task_ids": [15, 18] - } - } - ) - - approved: bool = Field( - ..., - description="True to approve tasks and start development, False to reject" - ) - excluded_task_ids: List[int] = Field( - default_factory=list, - description="List of task IDs to exclude from approval (they remain unchanged for later review)" - ) - - -class TaskApprovalResponse(BaseModel): - """Response model for task approval.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "phase": "active", - "approved_count": 23, - "excluded_count": 2, - "message": "Successfully approved 23 tasks. Development phase started." - } - } - ) - - success: bool = Field(..., description="Whether the approval operation succeeded") - phase: str = Field(..., description="Current project phase after approval") - approved_count: int = Field(..., description="Number of tasks that were approved") - excluded_count: int = Field(..., description="Number of tasks that were excluded") - message: str = Field(..., description="Human-readable status message") - - -@project_router.post( - "/{project_id}/tasks/approve", - response_model=TaskApprovalResponse, - summary="Approve tasks and start development", - description="Approves generated tasks and transitions the project from planning to active (development) phase. " - "After approval, multi-agent execution begins in the background. " - "Optionally exclude specific tasks from approval using excluded_task_ids.", - responses={ - 200: {"model": TaskApprovalResponse, "description": "Tasks approved, development started"}, - 400: {"description": "Project not in planning phase"}, - 401: {"description": "Not authenticated"}, - 403: {"description": "Access denied"}, - 404: {"description": "Project or tasks not found"}, - 500: {"description": "Failed to transition project phase"}, - }, -) -@rate_limit_standard() -async def approve_tasks( - request: Request, - project_id: int, - body: TaskApprovalRequest, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> TaskApprovalResponse: - """Approve tasks and transition project to development phase. - - This endpoint allows users to approve generated tasks after reviewing them. - Approved tasks are updated to 'pending' status and the project phase - transitions to 'active' (development). After approval, multi-agent execution - is triggered in the background. - - Args: - project_id: Project ID - request: Approval request with approved flag and optional exclusions - background_tasks: FastAPI background tasks for async execution - db: Database connection - current_user: Authenticated user - - Returns: - TaskApprovalResponse with summary of approval - - Raises: - HTTPException: - - 400: Project not in planning phase - - 403: Access denied - - 404: Project or tasks not found - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, - detail=f"Project {project_id} not found" - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Check if user is rejecting - if not body.approved: - return TaskApprovalResponse( - success=False, - phase=project.get("phase", "planning"), - approved_count=0, - excluded_count=0, - message="Tasks were not approved. Please review and modify tasks before approving." - ) - - # Validate project is in planning phase - current_phase = project.get("phase", "discovery") - if current_phase != "planning": - raise HTTPException( - status_code=400, - detail=f"Project must be in planning phase to approve tasks. Current phase: {current_phase}" - ) - - # Get all tasks for the project - tasks = db.get_project_tasks(project_id) - if not tasks: - raise HTTPException( - status_code=404, - detail="No tasks found for this project. Generate tasks before approving." - ) - - # Separate approved and excluded tasks - # Note: Excluded tasks remain unchanged in the database for audit trail. - # They are not deleted or modified - users can re-include them later if needed. - excluded_ids = set(body.excluded_task_ids) - approved_tasks = [t for t in tasks if t.id not in excluded_ids] - excluded_tasks = [t for t in tasks if t.id in excluded_ids] - - # Transition project phase to active FIRST (fails early before modifying tasks) - # This ensures we don't leave tasks in pending status if phase transition fails - try: - PhaseManager.transition(project_id, "active", db) - except ProjectNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except InvalidPhaseTransitionError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Failed to transition phase for project {project_id}: {e}", exc_info=True) - raise HTTPException( - status_code=500, - detail="Failed to transition project to development phase" - ) - - # Update approved tasks to pending status (after phase transition succeeds) - for task in approved_tasks: - db.update_task(task.id, {"status": "pending"}) - - # Broadcast development started event - await broadcast_development_started( - manager=manager, - project_id=project_id, - approved_count=len(approved_tasks), - excluded_count=len(excluded_tasks), - ) - - # START MULTI-AGENT EXECUTION IN BACKGROUND - # Schedule background task to create agents and start task execution - # This follows the same pattern as start_project_agent in agents.py - api_key = os.environ.get("ANTHROPIC_API_KEY") - if api_key: - # Schedule background task to start multi-agent execution - background_tasks.add_task( - start_development_execution, - project_id, - db, - manager, - api_key - ) - logger.info(f"✅ Scheduled multi-agent execution for project {project_id}") - else: - logger.warning( - f"⚠️ ANTHROPIC_API_KEY not configured - cannot start agents for project {project_id}" - ) - - logger.info( - f"Tasks approved for project {project_id}: " - f"{len(approved_tasks)} approved, {len(excluded_tasks)} excluded" - ) - - return TaskApprovalResponse( - success=True, - phase="active", - approved_count=len(approved_tasks), - excluded_count=len(excluded_tasks), - message=f"Successfully approved {len(approved_tasks)} tasks. Development phase started." - ) - - -# ============================================================================ -# Task Assignment Endpoint (Issue #248 - Manual trigger for stuck tasks) -# ============================================================================ - - -class TaskAssignmentResponse(BaseModel): - """Response model for task assignment.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "pending_count": 5, - "message": "Assignment started for 5 pending task(s)." - } - } - ) - - success: bool = Field(..., description="Whether the assignment operation was triggered") - pending_count: int = Field(..., description="Number of pending unassigned tasks found") - message: str = Field(..., description="Human-readable status message") - - -@project_router.post( - "/{project_id}/tasks/assign", - response_model=TaskAssignmentResponse, - summary="Manually trigger task assignment", - description="Restarts the multi-agent execution process for tasks stuck in 'pending' state. " - "Use this when tasks are unassigned after joining a session, when the original execution " - "failed/timed out, or when WebSocket messages were missed. " - "If execution is already in progress, returns status without starting a new execution.", - responses={ - 200: {"model": TaskAssignmentResponse, "description": "Assignment status"}, - 400: {"description": "Project not in active phase"}, - 401: {"description": "Not authenticated"}, - 403: {"description": "Access denied"}, - 404: {"description": "Project not found"}, - }, -) -@rate_limit_standard() -async def assign_pending_tasks( - request: Request, - project_id: int, - background_tasks: BackgroundTasks, - db: Database = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> TaskAssignmentResponse: - """Manually trigger task assignment for pending unassigned tasks. - - This endpoint allows users to restart the multi-agent execution process - when tasks are stuck in 'pending' state with no agent assigned. This can - happen when: - - User joins a session after the initial execution completed/failed - - The original execution timed out or crashed - - WebSocket messages were missed - - Args: - project_id: Project ID - background_tasks: FastAPI background tasks for async execution - db: Database connection - current_user: Authenticated user - - Returns: - TaskAssignmentResponse with pending task count and status - - Raises: - HTTPException: - - 400: Project not in active phase - - 403: Access denied - - 404: Project not found - """ - # Verify project exists - project = db.get_project(project_id) - if not project: - raise HTTPException( - status_code=404, - detail=f"Project {project_id} not found" - ) - - # Authorization check - if not db.user_has_project_access(current_user.id, project_id): - raise HTTPException(status_code=403, detail="Access denied") - - # Validate project is in active phase (development) - current_phase = project.get("phase", "discovery") - if current_phase != "active": - raise HTTPException( - status_code=400, - detail=f"Project must be in active (development) phase to assign tasks. Current phase: {current_phase}" - ) - - # Get all tasks and count pending unassigned ones - tasks = db.get_project_tasks(project_id) - pending_unassigned = [ - t for t in tasks - if t.status == TaskStatus.PENDING and not t.assigned_to - ] - pending_count = len(pending_unassigned) - - if pending_count == 0: - # Debug logging to help diagnose why tasks might appear stuck - logger.debug( - f"assign_pending_tasks called for project {project_id} but found 0 pending unassigned tasks. " - f"Total tasks: {len(tasks)}, statuses: {[t.status.value for t in tasks]}" - ) - return TaskAssignmentResponse( - success=True, - pending_count=0, - message="No pending unassigned tasks to assign." - ) - - # Check if execution is already in progress (Phase 1 fix for concurrent execution) - # Include ASSIGNED status to prevent race between assignment and execution start - executing_tasks = [ - t for t in tasks - if t.status in [TaskStatus.ASSIGNED, TaskStatus.IN_PROGRESS] - ] - if executing_tasks: - logger.info( - f"⏳ Execution already in progress for project {project_id}: " - f"{len(executing_tasks)} tasks assigned/running" - ) - return TaskAssignmentResponse( - success=True, - pending_count=pending_count, - message=f"Execution already in progress ({len(executing_tasks)} task(s) assigned/running). Please wait." - ) - - # Schedule multi-agent execution in background - api_key = os.environ.get("ANTHROPIC_API_KEY") - if not api_key: - logger.warning( - f"⚠️ ANTHROPIC_API_KEY not configured - cannot assign tasks for project {project_id}" - ) - return TaskAssignmentResponse( - success=False, - pending_count=pending_count, - message="Cannot assign tasks: API key not configured. Please contact administrator." - ) - - background_tasks.add_task( - start_development_execution, - project_id, - db, - manager, - api_key - ) - logger.info(f"✅ Scheduled task assignment for project {project_id} ({pending_count} pending tasks)") - - return TaskAssignmentResponse( - success=True, - pending_count=pending_count, - message=f"Assignment started for {pending_count} pending task(s)." - ) diff --git a/codeframe/ui/routers/templates.py b/codeframe/ui/routers/templates.py deleted file mode 100644 index 992d6848..00000000 --- a/codeframe/ui/routers/templates.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Task templates management router. - -This module provides API endpoints for: -- Listing available task templates -- Getting template details -- Applying templates to create tasks -""" - -import logging -from typing import Any, Dict, List, Literal, Optional - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field - -from codeframe.persistence.database import Database -from codeframe.ui.dependencies import get_db -from codeframe.planning.task_templates import TaskTemplateManager - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/templates", tags=["templates"]) - - -# ============================================================================ -# Response Models -# ============================================================================ - - -class TemplateTaskResponse(BaseModel): - """Response model for a template task.""" - - title: str - description: str - estimated_hours: float = Field(..., gt=0, description="Estimated hours (must be positive)") - complexity_score: int = Field(..., ge=1, le=5, description="Complexity rating (1-5)") - uncertainty_level: Literal["low", "medium", "high"] = Field( - ..., description="Uncertainty level" - ) - depends_on_indices: List[int] - tags: List[str] - - -class TemplateResponse(BaseModel): - """Response model for a task template.""" - - id: str - name: str - description: str - category: str - tags: List[str] - total_estimated_hours: float - tasks: List[TemplateTaskResponse] - - -class TemplateListResponse(BaseModel): - """Response model for template list.""" - - id: str - name: str - description: str - category: str - total_estimated_hours: float - - -class ApplyTemplateRequest(BaseModel): - """Request model for applying a template.""" - - template_id: str - context: Dict[str, Any] = Field(default_factory=dict) - issue_number: str = "1" - - -class ApplyTemplateResponse(BaseModel): - """Response model for applied template.""" - - template_id: str - tasks_created: int - task_ids: List[int] - - -class CategoryListResponse(BaseModel): - """Response model for category list.""" - - categories: List[str] - - -# ============================================================================ -# Template Endpoints -# ============================================================================ - - -@router.get("/", response_model=List[TemplateListResponse]) -async def list_templates( - category: Optional[str] = None, -) -> List[Dict[str, Any]]: - """List all available task templates. - - Args: - category: Optional category filter - - Returns: - List of templates with basic info - """ - try: - manager = TaskTemplateManager() - templates = manager.list_templates(category=category) - - return [ - { - "id": t.id, - "name": t.name, - "description": t.description, - "category": t.category, - "total_estimated_hours": t.total_estimated_hours, - } - for t in templates - ] - - except Exception as e: - logger.error(f"Error listing templates: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get("/categories", response_model=CategoryListResponse) -async def list_categories() -> Dict[str, List[str]]: - """List all template categories. - - Returns: - List of category names - """ - try: - manager = TaskTemplateManager() - categories = manager.get_categories() - return {"categories": categories} - - except Exception as e: - logger.error(f"Error listing categories: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get("/{template_id}", response_model=TemplateResponse) -async def get_template( - template_id: str, -) -> Dict[str, Any]: - """Get details for a specific template. - - Args: - template_id: Template ID - - Returns: - Template with full details including tasks - """ - try: - manager = TaskTemplateManager() - template = manager.get_template(template_id) - - if not template: - raise HTTPException(status_code=404, detail=f"Template '{template_id}' not found") - - return { - "id": template.id, - "name": template.name, - "description": template.description, - "category": template.category, - "tags": template.tags, - "total_estimated_hours": template.total_estimated_hours, - "tasks": [ - { - "title": task.title, - "description": task.description, - "estimated_hours": task.estimated_hours, - "complexity_score": task.complexity_score, - "uncertainty_level": task.uncertainty_level, - "depends_on_indices": task.depends_on_indices, - "tags": task.tags, - } - for task in template.tasks - ], - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting template {template_id}: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.post("/{project_id}/apply", response_model=ApplyTemplateResponse) -async def apply_template( - project_id: int, - request: ApplyTemplateRequest, - db: Database = Depends(get_db), -) -> Dict[str, Any]: - """Apply a template to create tasks for a project. - - Args: - project_id: Project ID - request: Template application request - db: Database connection - - Returns: - Result with created task IDs - """ - try: - manager = TaskTemplateManager() - template = manager.get_template(request.template_id) - - if not template: - raise HTTPException( - status_code=404, - detail=f"Template '{request.template_id}' not found" - ) - - # Check project exists - project = db.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Apply template to get task definitions - task_dicts = manager.apply_template( - template_id=request.template_id, - context=request.context, - issue_number=request.issue_number, - ) - - from codeframe.core.models import Issue, TaskStatus - - # Wrap all DB operations in a transaction for atomicity - with db.transaction(): - # Get first issue or create one - issues = db.get_project_issues(project_id) - if issues: - # Reuse existing issue - use ITS issue_number, not request.issue_number - matched_issue = issues[0] - issue_id = matched_issue.id - actual_issue_number = matched_issue.issue_number - else: - # Create a default issue for the tasks - default_issue = Issue( - project_id=project_id, - issue_number=request.issue_number, - title=f"Tasks from template: {template.name}", - description=f"Tasks generated from {template.id} template", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(default_issue) - actual_issue_number = request.issue_number - - # Create tasks in database with dependency tracking - created_tasks = [] # List of (task_id, depends_on_indices) - for task_dict in task_dicts: - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=task_dict["task_number"], - parent_issue_number=actual_issue_number, - title=task_dict["title"], - description=task_dict["description"], - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - estimated_hours=task_dict.get("estimated_hours"), - ) - created_tasks.append((task_id, task_dict.get("depends_on_indices", []))) - - # Wire up dependencies using indices -> actual task IDs - for task_id, dep_indices in created_tasks: - if dep_indices: - # Map 0-based indices to actual task IDs - depends_on_ids = [ - created_tasks[idx][0] - for idx in dep_indices - if 0 <= idx < len(created_tasks) - ] - for dep_id in depends_on_ids: - db.tasks.add_task_dependency(task_id, dep_id) - - created_task_ids = [task_id for task_id, _ in created_tasks] - - logger.info( - f"Applied template '{request.template_id}' to project {project_id}: " - f"{len(created_task_ids)} tasks created" - ) - - return { - "template_id": request.template_id, - "tasks_created": len(created_task_ids), - "task_ids": created_task_ids, - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error applying template to project {project_id}: {e}") - raise HTTPException(status_code=500, detail="Internal server error") diff --git a/codeframe/ui/routers/websocket.py b/codeframe/ui/routers/websocket.py deleted file mode 100644 index bd9c75e4..00000000 --- a/codeframe/ui/routers/websocket.py +++ /dev/null @@ -1,401 +0,0 @@ -"""WebSocket router for CodeFRAME. - -This module provides the WebSocket endpoint for real-time updates to the dashboard. - -Endpoints: - - WS /ws - WebSocket connection for real-time project updates - -Proactive Messaging: - The WebSocket sends proactive messages to clients: - - connection_ack: Sent after successful subscription to confirm real-time connectivity - - project_status: Initial project state snapshot sent on subscription - - heartbeat: Periodic messages (every 30 seconds) to keep connections alive - -Configuration: - WEBSOCKET_HEARTBEAT_INTERVAL: Environment variable to configure heartbeat interval (default: 30 seconds) -""" - -import asyncio -import json -import logging -import os -from datetime import datetime, UTC - -import jwt as pyjwt -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends -from sqlalchemy import select - -from codeframe.ui.shared import manager -from codeframe.ui.dependencies import get_db_websocket -from codeframe.persistence.database import Database -from codeframe.auth.manager import ( - SECRET, - JWT_ALGORITHM, - JWT_AUDIENCE, - get_async_session_maker, -) -from codeframe.auth.models import User - -# Module logger -logger = logging.getLogger(__name__) - -# Create router for WebSocket endpoint -# No prefix since WebSocket uses /ws path directly -router = APIRouter(tags=["websocket"]) - -# Heartbeat interval in seconds -# Heartbeats keep connections alive and verify real-time functionality -# Configurable via WEBSOCKET_HEARTBEAT_INTERVAL env var for testing/tuning -try: - HEARTBEAT_INTERVAL_SECONDS = int(os.getenv("WEBSOCKET_HEARTBEAT_INTERVAL", "30")) -except ValueError: - logger.warning("Invalid WEBSOCKET_HEARTBEAT_INTERVAL value, using default 30 seconds") - HEARTBEAT_INTERVAL_SECONDS = 30 - - -async def send_heartbeats(websocket: WebSocket, project_id: int) -> None: - """ - Send periodic heartbeat messages to a WebSocket client. - - This function runs in a background task and sends heartbeat messages - every HEARTBEAT_INTERVAL_SECONDS to keep the connection alive and - verify real-time functionality. - - Args: - websocket: WebSocket connection to send heartbeats to - project_id: Project ID to include in heartbeat messages - """ - try: - while True: - await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS) - try: - message = { - "type": "heartbeat", - "project_id": project_id, - "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - } - await websocket.send_json(message) - logger.debug(f"Sent heartbeat for project {project_id}") - except Exception as e: - # Connection may have closed - exit the loop - logger.debug(f"Heartbeat send failed (connection likely closed): {e}") - break - except asyncio.CancelledError: - # Normal cancellation on disconnect - logger.debug(f"Heartbeat task cancelled for project {project_id}") - raise - - -@router.get("/ws/health") -async def websocket_health(): - """ - Health check endpoint for WebSocket server. - - Returns status indicating WebSocket server is ready to accept connections. - Used by E2E tests and monitoring tools to verify WebSocket availability. - - Returns: - dict: Status indicating WebSocket server is ready - """ - return {"status": "ready"} - - -@router.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket, db: Database = Depends(get_db_websocket)): - """WebSocket connection for real-time updates with authentication. - - Handles real-time communication between the backend and frontend dashboard. - Supports ping/pong heartbeat and project subscription messages. - - Authentication: - - Requires token as query parameter: ws://host/ws?token=YOUR_JWT_TOKEN - - JWT token is validated and decoded on connection - - User ID is extracted from JWT claims and stored with WebSocket connection - - Project access is checked on subscribe/unsubscribe messages - - Args: - websocket: WebSocket connection instance - db: Database instance (injected) - - Message Types: - - ping: Client heartbeat (responds with pong) - - subscribe: Subscribe to specific project updates (requires integer project_id) - - unsubscribe: Unsubscribe from specific project updates (requires integer project_id) - - Message Format: - All messages must be valid JSON. Example: - - Ping: {"type": "ping"} - - Subscribe: {"type": "subscribe", "project_id": 1} - - Unsubscribe: {"type": "unsubscribe", "project_id": 1} - - Error Handling: - Invalid messages receive error responses with type "error". - Examples of invalid messages: - - Missing project_id in subscribe/unsubscribe - - Non-integer project_id (e.g., string or float) - - Non-positive project_id (≤ 0) - - Malformed JSON - - Broadcasts: - - agent_started: When an agent starts - - status_update: Project status changes - - chat_message: New chat messages - - task_assigned: Task assignments - - task_completed: Task completions - - blocker_created: New blockers - """ - # Authentication: Extract and validate JWT token from query parameters - # Authentication is always required for WebSocket connections - token = websocket.query_params.get("token") - - if not token: - # No token provided - reject connection - await websocket.close(code=1008, reason="Authentication required: missing token") - return - - # Validate JWT token (same logic as HTTP auth in auth/dependencies.py) - try: - payload = pyjwt.decode( - token, - SECRET, - algorithms=[JWT_ALGORITHM], - audience=JWT_AUDIENCE, - ) - user_id_str = payload.get("sub") - if not user_id_str: - await websocket.close(code=1008, reason="Invalid token: missing subject") - return - user_id = int(user_id_str) - except pyjwt.ExpiredSignatureError: - await websocket.close(code=1008, reason="Token expired") - return - except (pyjwt.InvalidTokenError, ValueError) as e: - logger.debug(f"WebSocket JWT decode error: {e}") - await websocket.close(code=1008, reason="Invalid authentication token") - return - - # Verify user exists and is active - try: - async_session_maker = get_async_session_maker() - async with async_session_maker() as session: - result = await session.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if user is None: - await websocket.close(code=1008, reason="User not found") - return - - if not user.is_active: - await websocket.close(code=1008, reason="User is inactive") - return - except Exception as e: - logger.error(f"WebSocket user lookup error: {e}") - await websocket.close(code=1008, reason="Authentication failed") - return - - logger.info(f"WebSocket authenticated: user_id={user_id}") - - # Accept WebSocket connection after successful authentication - # Note: user_id is captured in closure and used for authorization checks below - await manager.connect(websocket) - - # Track heartbeat tasks per project to cancel on disconnect - heartbeat_tasks: dict[int, asyncio.Task] = {} - - try: - while True: - # Keep connection alive and handle incoming messages - data = await websocket.receive_text() - - # Parse JSON with error handling - try: - message = json.loads(data) - except json.JSONDecodeError as e: - # Malformed JSON - log warning and send error response to client - logger.warning(f"Malformed JSON from WebSocket client: {e}") - try: - await websocket.send_json({"type": "error", "error": "Invalid JSON format"}) - except Exception: - # If we can't send error response, just continue - pass - continue # Skip this message and continue receiving - - # Handle different message types - if message.get("type") == "ping": - await websocket.send_json({"type": "pong"}) - elif message.get("type") == "subscribe": - # Subscribe to specific project updates - project_id = message.get("project_id") - - # Validate project_id is present - if project_id is None: - logger.warning("Subscribe message missing project_id") - await websocket.send_json( - {"type": "error", "error": "Subscribe message requires project_id"} - ) - continue - - # Validate project_id is an integer - if not isinstance(project_id, int): - logger.warning(f"Invalid project_id type: {type(project_id).__name__}") - await websocket.send_json( - { - "type": "error", - "error": f"project_id must be an integer, got {type(project_id).__name__}", - } - ) - continue - - # Validate project_id is positive - if project_id <= 0: - logger.warning(f"Invalid project_id: {project_id}") - await websocket.send_json( - {"type": "error", "error": "project_id must be a positive integer"} - ) - continue - - # Authorization check: Verify user has access to project - if not db.user_has_project_access(user_id, project_id): - logger.warning(f"User {user_id} denied access to project {project_id}") - await websocket.send_json( - { - "type": "error", - "error": "Access denied: you do not have permission to access this project", - } - ) - continue - - # Track subscription - try: - await manager.subscription_manager.subscribe(websocket, project_id) - logger.info(f"WebSocket (user_id={user_id}) subscribed to project {project_id}") - await websocket.send_json({"type": "subscribed", "project_id": project_id}) - - # Send connection acknowledgment (proactive messaging) - await websocket.send_json({ - "type": "connection_ack", - "project_id": project_id, - "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - "message": "Connected to real-time updates", - }) - - # Send initial project status snapshot (proactive messaging) - try: - project = db.get_project(project_id) - if project: - await websocket.send_json({ - "type": "project_status", - "project_id": project_id, - "status": project.get("status", "unknown"), - "phase": project.get("phase", "unknown"), - "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - }) - else: - logger.debug(f"Project {project_id} not found for status snapshot") - except (KeyError, AttributeError) as e: - # Expected - project may not have status/phase fields - logger.debug(f"Project {project_id} missing status fields: {e}") - except Exception as e: - # Unexpected error - log as warning for investigation - logger.warning(f"Unexpected error sending project status for {project_id}: {e}") - - # Start heartbeat task for this subscription (proactive messaging) - # Cancel existing task if present (handles re-subscription case to prevent memory leak) - if project_id in heartbeat_tasks: - old_task = heartbeat_tasks[project_id] - old_task.cancel() - try: - await old_task - except asyncio.CancelledError: - pass - logger.debug(f"Cancelled old heartbeat task for project {project_id}") - - # Start new heartbeat task - heartbeat_task = asyncio.create_task( - send_heartbeats(websocket, project_id) - ) - heartbeat_tasks[project_id] = heartbeat_task - logger.debug(f"Started heartbeat task for project {project_id}") - - except Exception as e: - logger.error(f"Error subscribing to project {project_id}: {e}") - await websocket.send_json( - {"type": "error", "error": "Failed to subscribe to project"} - ) - elif message.get("type") == "unsubscribe": - # Unsubscribe from specific project updates - project_id = message.get("project_id") - - # Validate project_id is present - if project_id is None: - logger.warning("Unsubscribe message missing project_id") - await websocket.send_json( - {"type": "error", "error": "Unsubscribe message requires project_id"} - ) - continue - - # Validate project_id is an integer - if not isinstance(project_id, int): - logger.warning(f"Invalid project_id type: {type(project_id).__name__}") - await websocket.send_json( - { - "type": "error", - "error": f"project_id must be an integer, got {type(project_id).__name__}", - } - ) - continue - - # Validate project_id is positive - if project_id <= 0: - logger.warning(f"Invalid project_id: {project_id}") - await websocket.send_json( - {"type": "error", "error": "project_id must be a positive integer"} - ) - continue - - # Remove subscription - try: - await manager.subscription_manager.unsubscribe(websocket, project_id) - logger.info(f"WebSocket unsubscribed from project {project_id}") - await websocket.send_json({"type": "unsubscribed", "project_id": project_id}) - - # Cancel heartbeat task for this project - if project_id in heartbeat_tasks: - heartbeat_tasks[project_id].cancel() - try: - await heartbeat_tasks[project_id] - except asyncio.CancelledError: - pass - del heartbeat_tasks[project_id] - logger.debug(f"Cancelled heartbeat task for unsubscribed project {project_id}") - - except Exception as e: - logger.error(f"Error unsubscribing from project {project_id}: {e}") - await websocket.send_json( - {"type": "error", "error": "Failed to unsubscribe from project"} - ) - - except WebSocketDisconnect: - # Normal client disconnect - no error logging needed - logger.debug("WebSocket client disconnected normally") - except Exception as e: - # Log unexpected errors for debugging - logger.error(f"WebSocket error: {type(e).__name__} - {str(e)}", exc_info=True) - finally: - # Cancel all heartbeat tasks - for project_id, task in heartbeat_tasks.items(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - logger.debug(f"Cancelled heartbeat task for project {project_id}") - heartbeat_tasks.clear() - - # Always disconnect and clean up, regardless of how we exited - await manager.disconnect(websocket) - try: - await websocket.close() - except Exception: - # Socket may already be closed - ignore errors - pass diff --git a/codeframe/ui/services/__init__.py b/codeframe/ui/services/__init__.py deleted file mode 100644 index 3516c9b5..00000000 --- a/codeframe/ui/services/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Service layer for FastAPI server. - -This package contains business logic services that are used by multiple -routers, providing a clean separation between HTTP layer and business logic. -""" diff --git a/codeframe/ui/services/agent_service.py b/codeframe/ui/services/agent_service.py deleted file mode 100644 index 513e2360..00000000 --- a/codeframe/ui/services/agent_service.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Agent lifecycle management service. - -This module provides business logic for managing agent lifecycle operations -such as starting, stopping, pausing, and resuming agents. -""" - -from typing import Dict, Optional -import asyncio -import logging - -from codeframe.agents.lead_agent import LeadAgent -from codeframe.persistence.database import Database -from codeframe.core.models import ProjectStatus - -logger = logging.getLogger(__name__) - - -class AgentService: - """Service for managing agent lifecycle operations.""" - - def __init__(self, db: Database, running_agents: Dict[int, LeadAgent]): - """Initialize agent service. - - Args: - db: Database connection - running_agents: Dictionary mapping project_id to LeadAgent instances - """ - self.db = db - self.running_agents = running_agents - - async def stop_agent(self, project_id: int) -> bool: - """Stop a running agent for a project. - - Args: - project_id: Project ID - - Returns: - True if agent was stopped, False if no agent was running - """ - if project_id not in self.running_agents: - logger.warning(f"No running agent found for project {project_id}") - return False - - try: - # Update project status first (atomic persistence) - await asyncio.to_thread( - self.db.update_project, project_id, {"status": ProjectStatus.STOPPED.value} - ) - - # Remove agent from tracking after status is persisted - self.running_agents.pop(project_id, None) - - logger.info(f"Stopped agent for project {project_id}") - return True - - except Exception as e: - logger.error(f"Failed to stop agent for project {project_id}: {e}", exc_info=True) - return False - - async def pause_agent(self, project_id: int) -> bool: - """Pause a running agent without stopping it. - - Args: - project_id: Project ID - - Returns: - True if agent was paused, False if no agent was running - """ - if project_id not in self.running_agents: - logger.warning(f"No running agent found for project {project_id}") - return False - - try: - # Update project status to PAUSED - await asyncio.to_thread( - self.db.update_project, project_id, {"status": ProjectStatus.PAUSED.value} - ) - - logger.info(f"Paused agent for project {project_id}") - return True - - except Exception as e: - logger.error(f"Failed to pause agent for project {project_id}: {e}", exc_info=True) - return False - - async def resume_agent(self, project_id: int) -> bool: - """Resume a paused agent. - - Args: - project_id: Project ID - - Returns: - True if agent was resumed, False if no agent was found - """ - if project_id not in self.running_agents: - logger.warning(f"No running agent found for project {project_id}") - return False - - try: - # Update project status back to RUNNING - await asyncio.to_thread( - self.db.update_project, project_id, {"status": ProjectStatus.RUNNING.value} - ) - - logger.info(f"Resumed agent for project {project_id}") - return True - - except Exception as e: - logger.error(f"Failed to resume agent for project {project_id}: {e}", exc_info=True) - return False - - def get_running_agent(self, project_id: int) -> Optional[LeadAgent]: - """Get the running agent for a project. - - Args: - project_id: Project ID - - Returns: - LeadAgent instance if running, None otherwise - """ - return self.running_agents.get(project_id) - - def is_agent_running(self, project_id: int) -> bool: - """Check if an agent is running for a project. - - Args: - project_id: Project ID - - Returns: - True if agent is running, False otherwise - """ - return project_id in self.running_agents diff --git a/codeframe/ui/services/review_service.py b/codeframe/ui/services/review_service.py deleted file mode 100644 index 860c6d94..00000000 --- a/codeframe/ui/services/review_service.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Code review service. - -This module provides business logic for managing code review operations -and caching review results. -""" - -from typing import Dict, Optional -import logging -from datetime import datetime, timezone - -from codeframe.persistence.database import Database - -logger = logging.getLogger(__name__) - - -class ReviewService: - """Service for managing code review operations.""" - - def __init__(self, db: Database, review_cache: Dict[int, dict]): - """Initialize review service. - - Args: - db: Database connection - review_cache: Dictionary mapping task_id to review report dict - """ - self.db = db - self.review_cache = review_cache - - def cache_review(self, task_id: int, review_data: dict) -> None: - """Cache a review report for quick access. - - Args: - task_id: Task ID - review_data: Review report data to cache - """ - self.review_cache[task_id] = { - **review_data, - "cached_at": datetime.now(timezone.utc).isoformat(), - } - logger.debug(f"Cached review for task {task_id}") - - def get_cached_review(self, task_id: int) -> Optional[dict]: - """Get a cached review report. - - Args: - task_id: Task ID - - Returns: - Cached review data if exists, None otherwise - """ - return self.review_cache.get(task_id) - - def clear_cache(self, task_id: Optional[int] = None) -> None: - """Clear review cache. - - Args: - task_id: Task ID to clear, or None to clear all - """ - if task_id is not None: - if task_id in self.review_cache: - del self.review_cache[task_id] - logger.debug(f"Cleared cache for task {task_id}") - else: - self.review_cache.clear() - logger.debug("Cleared all review cache") - - def get_cache_stats(self) -> dict: - """Get cache statistics. - - Returns: - Dictionary with cache statistics - """ - return { - "total_cached_reviews": len(self.review_cache), - "cached_task_ids": list(self.review_cache.keys()), - } - - async def get_review_summary(self, task_id: int) -> Optional[dict]: - """Get review summary for a task. - - First checks cache, then falls back to database. - - Args: - task_id: Task ID - - Returns: - Review summary if exists, None otherwise - """ - # Check cache first - cached = self.get_cached_review(task_id) - if cached is not None: - return cached - - # Fall back to database - # Note: Database methods for reviews should be added to Database class - # For now, return None if not cached - logger.debug(f"No cached review found for task {task_id}") - return None diff --git a/tests/agents/test_agent_lifecycle.py b/tests/agents/test_agent_lifecycle.py deleted file mode 100644 index a7081328..00000000 --- a/tests/agents/test_agent_lifecycle.py +++ /dev/null @@ -1,768 +0,0 @@ -"""Tests for Agent Lifecycle (cf-10) - Project Start & Agent Lifecycle. - -Following strict TDD methodology: Tests written FIRST (RED phase). -Target: 100% coverage for agent lifecycle functionality. - -Requirements from cf-10: -- cf-10.1: Status Server agent management with running_agents dictionary -- cf-10.2: POST /api/projects/{id}/start endpoint (202 Accepted, non-blocking) -- cf-10.3: Lead Agent greeting on start -- cf-10.4: WebSocket message protocol and broadcasting - -Definition of Done: -- ✅ POST /api/projects/{id}/start starts Lead Agent -- ✅ Project status changes to "running" -- ✅ Greeting message saved to database -- ✅ WebSocket broadcasts work -- ✅ Agent runs in background -- ✅ 100% TDD compliance (tests FIRST) -- ✅ All tests pass (100% pass rate) -""" - -import asyncio -import os - -import pytest -from fastapi.testclient import TestClient -from importlib import reload -from unittest.mock import AsyncMock, Mock, patch - -from codeframe.core.models import ProjectStatus -from codeframe.persistence.database import Database - - -@pytest.fixture(autouse=True) -def clear_shared_state(): - """Clear shared_state before each test to prevent state leakage. - - Since shared_state uses global dictionaries that persist across tests, - we need to clear them before each test to ensure test isolation. - """ - from codeframe.ui.shared import shared_state - - # Clear before test - shared_state._running_agents.clear() - shared_state._review_cache.clear() - - yield - - # Clear after test - shared_state._running_agents.clear() - shared_state._review_cache.clear() - - -@pytest.fixture -def temp_db_for_lifecycle(tmp_path): - """Create temporary database for lifecycle tests.""" - db_path = tmp_path / "test_lifecycle.db" - - # Save original value - original_db_path = os.environ.get("DATABASE_PATH") - os.environ["DATABASE_PATH"] = str(db_path) - - db = Database(db_path) - db.initialize() - - yield db - - db.close() - if db_path.exists(): - db_path.unlink() - - # Restore original value - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - else: - os.environ.pop("DATABASE_PATH", None) - - -@pytest.fixture -def test_client_with_db(temp_db_path, tmp_path): - """Create test client with properly initialized database and authentication. - - Follows the pattern from test_project_creation_api.py: - 1. Set DATABASE_PATH environment variable - 2. Set WORKSPACE_ROOT to temporary directory to avoid collisions - 3. Reset auth engine to pick up new DATABASE_PATH - 4. Reload server module to pick up new env vars - 5. Create test user and add authentication headers - 6. Use TestClient which triggers lifespan initialization - """ - from tests.helpers import create_test_jwt_token, setup_test_user - - # Save original values - original_db_path = os.environ.get("DATABASE_PATH") - original_workspace_root = os.environ.get("WORKSPACE_ROOT") - - # Set environment variables - os.environ["DATABASE_PATH"] = str(temp_db_path) - - # Set temporary workspace root to avoid collisions between test runs - workspace_root = tmp_path / "workspaces" - os.environ["WORKSPACE_ROOT"] = str(workspace_root) - - # Reset auth engine to pick up new DATABASE_PATH - from codeframe.auth.manager import reset_auth_engine - reset_auth_engine() - - # Reload server to pick up new DATABASE_PATH and WORKSPACE_ROOT - from codeframe.ui import server - - reload(server) - - # TestClient will trigger lifespan which initializes app.state.db - with TestClient(server.app) as client: - # Create test user for authentication - db = server.app.state.db - setup_test_user(db, user_id=1) - - # Add authentication header - auth_token = create_test_jwt_token(user_id=1) - client.headers["Authorization"] = f"Bearer {auth_token}" - - yield client - - # Restore original values - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - else: - os.environ.pop("DATABASE_PATH", None) - - if original_workspace_root is not None: - os.environ["WORKSPACE_ROOT"] = original_workspace_root - else: - os.environ.pop("WORKSPACE_ROOT", None) - - # Reset auth engine for next test - reset_auth_engine() - - -@pytest.fixture -def sample_project(test_client_with_db): - """Create a sample project for lifecycle tests.""" - response = test_client_with_db.post( - "/api/projects", - json={"name": "Lifecycle Test Project", "description": "Test project for lifecycle tests"}, - ) - assert response.status_code == 201 - return response.json() - - -@pytest.mark.unit -class TestStartAgentEndpoint: - """Test POST /api/projects/{id}/start endpoint (cf-10.2).""" - - def test_start_agent_endpoint_returns_202_accepted( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint returns 202 Accepted immediately (non-blocking). - - Requirement: cf-10.2 - Return 202 Accepted immediately (non-blocking) - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # ACT - with patch("codeframe.ui.routers.agents.start_agent") as mock_start_agent: - mock_start_agent.return_value = AsyncMock() - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - assert response.status_code == 202 - assert "message" in response.json() - assert "starting" in response.json()["message"].lower() - - def test_start_agent_endpoint_handles_nonexistent_project(self, test_client_with_db): - """Test that start endpoint returns 404 for nonexistent project. - - Requirement: cf-10.2 - Handle nonexistent projects gracefully - """ - # ARRANGE - nonexistent_id = 99999 - - # ACT - response = test_client_with_db.post(f"/api/projects/{nonexistent_id}/start") - - # ASSERT - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - def test_start_agent_endpoint_handles_already_running( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint is idempotent for already running projects. - - Requirement: cf-10.2 - Idempotent behavior for already running agents - - Updated for fix/start-discovery: Returns 200 only when discovery is - already in progress (not idle). When discovery is idle, endpoint - should start discovery regardless of project status. - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # Update project status to RUNNING - get db from reloaded server - from codeframe.ui import server - - db = server.app.state.db - db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - - # Mock LeadAgent's get_discovery_status to return discovering state - # (discovery already in progress - so "already running" is correct) - mock_agent = Mock() - mock_agent.get_discovery_status.return_value = { - "state": "discovering", - "answered_count": 1, - "total_required": 5, - "progress_percentage": 20.0, - "answers": {"q1": "answer1"}, - } - - # ACT - with patch("codeframe.ui.routers.agents.start_agent") as mock_start_agent: - mock_start_agent.return_value = AsyncMock() - with patch("codeframe.ui.routers.agents.LeadAgent", return_value=mock_agent): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - assert response.status_code == 200 # Already running - assert ( - "already" in response.json()["message"].lower() - or "running" in response.json()["message"].lower() - ) - - def test_start_agent_endpoint_triggers_background_task( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint triggers background task execution. - - Requirement: cf-10.2 - Call start_agent in background task - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # ACT - with patch("codeframe.ui.routers.agents.start_agent"): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - assert response.status_code == 202 - - -@pytest.mark.unit -class TestStartAgentFunction: - """Test start_agent async function (cf-10.1).""" - - @pytest.mark.asyncio - async def test_start_agent_creates_lead_agent_instance(self, temp_db_for_lifecycle): - """Test that start_agent creates LeadAgent instance. - - Requirement: cf-10.1 - Create and store agent reference - """ - # ARRANGE - project_id = temp_db_for_lifecycle.create_project("Test Project", "Test Project project") - - # Initialize running_agents dictionary - running_agents = {} - - # ACT - with patch("codeframe.ui.shared.LeadAgent") as mock_lead_agent_class: - mock_agent = Mock() - mock_lead_agent_class.return_value = mock_agent - - from codeframe.ui.shared import start_agent - - await start_agent(project_id, temp_db_for_lifecycle, running_agents, "test-api-key") - - # ASSERT - assert project_id in running_agents - mock_lead_agent_class.assert_called_once() - - @pytest.mark.asyncio - async def test_start_agent_updates_project_status_to_running(self, temp_db_for_lifecycle): - """Test that start_agent updates project status to RUNNING. - - Requirement: cf-10.1 - Update project status to "running" - """ - # ARRANGE - project_id = temp_db_for_lifecycle.create_project("Test Project", "Test Project project") - running_agents = {} - - # ACT - with patch("codeframe.ui.shared.LeadAgent"): - from codeframe.ui.shared import start_agent - - await start_agent(project_id, temp_db_for_lifecycle, running_agents, "test-api-key") - - # ASSERT - project = temp_db_for_lifecycle.get_project(project_id) - assert project["status"] == ProjectStatus.RUNNING.value - - @pytest.mark.asyncio - async def test_start_agent_saves_greeting_to_database(self, temp_db_for_lifecycle): - """Test that start_agent saves greeting message to conversation history. - - Requirement: cf-10.3 - Save greeting to conversation history - """ - # ARRANGE - project_id = temp_db_for_lifecycle.create_project("Test Project", "Test Project project") - running_agents = {} - expected_greeting = "Hi! I'm your Lead Agent. I'm here to help build your project. What would you like to create?" - - # ACT - with patch("codeframe.ui.shared.LeadAgent"): - from codeframe.ui.shared import start_agent - - await start_agent(project_id, temp_db_for_lifecycle, running_agents, "test-api-key") - - # ASSERT - conversation = temp_db_for_lifecycle.get_conversation(project_id) - assert len(conversation) == 1 - assert conversation[0]["key"] == "assistant" - assert expected_greeting in conversation[0]["value"] - - @pytest.mark.asyncio - async def test_start_agent_broadcasts_via_websocket(self, temp_db_for_lifecycle): - """Test that start_agent broadcasts messages via WebSocket. - - Requirement: cf-10.4 - Broadcast messages via WebSocket - """ - # ARRANGE - project_id = temp_db_for_lifecycle.create_project("Test Project", "Test Project project") - running_agents = {} - - # ACT - with patch("codeframe.ui.shared.LeadAgent"): - with patch("codeframe.ui.shared.manager.broadcast") as mock_broadcast: - from codeframe.ui.shared import start_agent - - await start_agent(project_id, temp_db_for_lifecycle, running_agents, "test-api-key") - - # ASSERT - # Should broadcast at least 2 messages: agent_started and chat_message (greeting) - assert mock_broadcast.call_count >= 2 - - # Verify message types - calls = mock_broadcast.call_args_list - message_types = [call[0][0]["type"] for call in calls] - assert "agent_started" in message_types or "status_update" in message_types - assert "chat_message" in message_types - - -@pytest.mark.unit -class TestWebSocketMessageProtocol: - """Test WebSocket message protocol (cf-10.4).""" - - def test_broadcast_message_formats_status_update(self): - """Test that broadcast_message formats status_update correctly. - - Requirement: cf-10.4 - Define message types: status_update - """ - # ARRANGE - mock_manager = Mock() - mock_manager.broadcast = AsyncMock() - - # ACT - - message = {"type": "status_update", "project_id": 1, "status": "running"} - asyncio.run(mock_manager.broadcast(message)) - - # ASSERT - mock_manager.broadcast.assert_called_once() - call_args = mock_manager.broadcast.call_args[0][0] - assert call_args["type"] == "status_update" - - def test_broadcast_message_formats_chat_message(self): - """Test that broadcast_message formats chat_message correctly. - - Requirement: cf-10.4 - Define message types: chat_message - """ - # ARRANGE - mock_manager = Mock() - mock_manager.broadcast = AsyncMock() - - # ACT - - message = { - "type": "chat_message", - "project_id": 1, - "role": "assistant", - "content": "Hello!", - } - asyncio.run(mock_manager.broadcast(message)) - - # ASSERT - mock_manager.broadcast.assert_called_once() - call_args = mock_manager.broadcast.call_args[0][0] - assert call_args["type"] == "chat_message" - assert "content" in call_args - - def test_broadcast_message_formats_agent_started(self): - """Test that broadcast_message formats agent_started correctly. - - Requirement: cf-10.4 - Define message types: agent_started - """ - # ARRANGE - mock_manager = Mock() - mock_manager.broadcast = AsyncMock() - - # ACT - - message = {"type": "agent_started", "project_id": 1, "agent_type": "lead"} - asyncio.run(mock_manager.broadcast(message)) - - # ASSERT - mock_manager.broadcast.assert_called_once() - call_args = mock_manager.broadcast.call_args[0][0] - assert call_args["type"] == "agent_started" - - -@pytest.mark.integration -class TestAgentLifecycleIntegration: - """Integration test for complete agent lifecycle workflow.""" - - def test_complete_start_workflow_end_to_end( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test complete workflow from start request to agent running. - - Integration test covering: - - POST /api/projects/{id}/start returns 202 - - Project status changes to RUNNING - - Greeting saved to database - - WebSocket broadcasts sent - - Agent instance created and stored - - Requirements: cf-10.1, cf-10.2, cf-10.3, cf-10.4 - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - # Get db from reloaded server - from codeframe.ui import server - - db = server.app.state.db - - # Verify initial state - project = db.get_project(project_id) - assert project["status"] == ProjectStatus.INIT.value # Database returns string - - initial_conversation = db.get_conversation(project_id) - assert len(initial_conversation) == 0 - - # ACT - with patch("codeframe.ui.shared.LeadAgent") as mock_lead_agent_class: - with patch("codeframe.ui.shared.manager.broadcast") as mock_broadcast: - # Mock LeadAgent - mock_agent = Mock() - mock_lead_agent_class.return_value = mock_agent - - # Send start request - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # Give background task time to execute - import time - - time.sleep(0.5) - - # ASSERT - # 1. Endpoint returns 202 Accepted - assert response.status_code == 202 - - # 2. Project status updated to RUNNING - project = db.get_project(project_id) - assert project["status"] == ProjectStatus.RUNNING.value - - # 3. Greeting saved to database - conversation = db.get_conversation(project_id) - assert len(conversation) >= 1 - greeting_message = conversation[0] - assert greeting_message["key"] == "assistant" - assert "Lead Agent" in greeting_message["value"] - - # 4. WebSocket broadcasts sent - assert mock_broadcast.call_count >= 2 - - # 5. Agent instance created - mock_lead_agent_class.assert_called_once() - - -@pytest.mark.unit -class TestRunningAgentsDictionary: - """Test running_agents dictionary management (cf-10.1).""" - - def test_running_agents_dictionary_stores_agent_reference(self): - """Test that running_agents stores agent by project_id. - - Requirement: cf-10.1 - Store agent reference in dictionary - """ - # ARRANGE - running_agents = {} - project_id = 1 - mock_agent = Mock() - - # ACT - running_agents[project_id] = mock_agent - - # ASSERT - assert project_id in running_agents - assert running_agents[project_id] == mock_agent - - def test_running_agents_dictionary_handles_multiple_projects(self): - """Test that running_agents can handle multiple concurrent projects. - - Requirement: cf-10.1 - Support multiple concurrent agents - """ - # ARRANGE - running_agents = {} - - # ACT - running_agents[1] = Mock() - running_agents[2] = Mock() - running_agents[3] = Mock() - - # ASSERT - assert len(running_agents) == 3 - assert 1 in running_agents - assert 2 in running_agents - assert 3 in running_agents - - def test_running_agents_dictionary_allows_agent_removal(self): - """Test that agents can be removed from running_agents. - - Requirement: cf-10.1 - Support agent lifecycle (stop) - """ - # ARRANGE - running_agents = {1: Mock(), 2: Mock()} - - # ACT - del running_agents[1] - - # ASSERT - assert 1 not in running_agents - assert 2 in running_agents - assert len(running_agents) == 1 - - -@pytest.mark.unit -class TestAgentLifecycleErrorHandling: - """Test error handling in agent lifecycle.""" - - def test_start_agent_handles_database_error_gracefully(self, test_client_with_db): - """Test that start_agent handles database errors gracefully.""" - # ARRANGE - project_id = 1 - - # ACT - Mock get_project to return None (simulating not found) - with patch("codeframe.ui.server.app.state.db.get_project", return_value=None): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - Should return 404 when project not found - assert response.status_code == 404 - - @pytest.mark.asyncio - async def test_start_agent_handles_lead_agent_initialization_error(self, temp_db_for_lifecycle): - """Test that start_agent handles LeadAgent initialization errors.""" - # ARRANGE - project_id = temp_db_for_lifecycle.create_project("Test Project", "Test Project project") - running_agents = {} - - # ACT & ASSERT - with patch("codeframe.ui.shared.LeadAgent", side_effect=ValueError("Missing API key")): - from codeframe.ui.shared import start_agent - - with pytest.raises(ValueError): - await start_agent(project_id, temp_db_for_lifecycle, running_agents, None) - - @pytest.mark.asyncio - async def test_start_agent_handles_websocket_broadcast_failure(self, temp_db_for_lifecycle): - """Test that start_agent continues even if WebSocket broadcast fails.""" - # ARRANGE - project_id = temp_db_for_lifecycle.create_project("Test Project", "Test Project project") - running_agents = {} - - # ACT - with patch("codeframe.ui.shared.LeadAgent"): - with patch("codeframe.ui.shared.manager.broadcast", side_effect=Exception("WS Error")): - from codeframe.ui.shared import start_agent - - # Should not raise exception - await start_agent(project_id, temp_db_for_lifecycle, running_agents, "test-api-key") - - # ASSERT - Agent still created despite broadcast failure - project = temp_db_for_lifecycle.get_project(project_id) - assert project["status"] == ProjectStatus.RUNNING.value - - -@pytest.mark.unit -class TestStartEndpointDiscoveryState: - """Test /start endpoint behavior based on discovery state. - - Fix for issue: When project is "running" but discovery is "idle", the - "Start Discovery" button should still work. The /start endpoint should - check discovery state, not just project status. - """ - - def test_start_endpoint_starts_discovery_when_project_running_but_discovery_idle( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint proceeds when project is running but discovery is idle. - - Scenario: - - Project status: "running" - - Discovery state: "idle" (no discovery started) - - Expected: 202 Accepted (start discovery) - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # Update project status to RUNNING but keep discovery idle - from codeframe.ui import server - - db = server.app.state.db - db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - - # Mock LeadAgent's get_discovery_status to return idle state - mock_agent = Mock() - mock_agent.get_discovery_status.return_value = { - "state": "idle", - "answered_count": 0, - "answers": {}, - } - - # ACT - with patch("codeframe.ui.routers.agents.start_agent") as mock_start_agent: - mock_start_agent.return_value = AsyncMock() - with patch("codeframe.ui.routers.agents.LeadAgent", return_value=mock_agent): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - Should return 202 Accepted, not 200 "already running" - assert response.status_code == 202 - assert "starting" in response.json()["message"].lower() or "start" in response.json()["message"].lower() - # start_agent should have been called to initiate discovery - mock_start_agent.assert_called_once() - - def test_start_endpoint_returns_already_running_when_discovery_active( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint returns 200 when discovery is already in progress. - - Scenario: - - Project status: "running" - - Discovery state: "discovering" (discovery already active) - - Expected: 200 OK with "already running" message - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # Update project status to RUNNING - from codeframe.ui import server - - db = server.app.state.db - db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - - # Mock LeadAgent's get_discovery_status to return discovering state - mock_agent = Mock() - mock_agent.get_discovery_status.return_value = { - "state": "discovering", - "answered_count": 2, - "total_required": 5, - "progress_percentage": 40.0, - "answers": {"q1": "answer1", "q2": "answer2"}, - } - - # ACT - with patch("codeframe.ui.routers.agents.start_agent") as mock_start_agent: - with patch("codeframe.ui.routers.agents.LeadAgent", return_value=mock_agent): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - Should return 200 OK with already running message - assert response.status_code == 200 - response_data = response.json() - assert "running" in response_data.get("status", "").lower() or "running" in response_data.get("message", "").lower() - # start_agent should NOT have been called - mock_start_agent.assert_not_called() - - def test_start_endpoint_handles_discovery_completed( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint returns appropriate response when discovery is completed. - - Scenario: - - Project status: "running" - - Discovery state: "completed" - - Expected: 200 OK with "discovery completed" or "already running" message - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # Update project status to RUNNING - from codeframe.ui import server - - db = server.app.state.db - db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - - # Mock LeadAgent's get_discovery_status to return completed state - mock_agent = Mock() - mock_agent.get_discovery_status.return_value = { - "state": "completed", - "answered_count": 5, - "total_required": 5, - "progress_percentage": 100.0, - "answers": {"q1": "a1", "q2": "a2", "q3": "a3", "q4": "a4", "q5": "a5"}, - } - - # ACT - with patch("codeframe.ui.routers.agents.start_agent") as mock_start_agent: - with patch("codeframe.ui.routers.agents.LeadAgent", return_value=mock_agent): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - Should return 200 OK - assert response.status_code == 200 - response_data = response.json() - # Either "completed" or "running" message is acceptable - message = response_data.get("message", "").lower() - status = response_data.get("status", "").lower() - assert "complet" in message or "running" in message or "complet" in status or "running" in status - # start_agent should NOT have been called since discovery is already done - mock_start_agent.assert_not_called() - - def test_start_endpoint_handles_discovery_status_check_failure( - self, test_client_with_db, sample_project, monkeypatch - ): - """Test that start endpoint proceeds when discovery status check fails. - - Scenario: - - Project status: "running" - - Discovery status check: raises exception (e.g., database error) - - Expected: 202 Accepted (fall back to normal start flow) - """ - # ARRANGE - project_id = sample_project["id"] - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-api-key") - - # Update project status to RUNNING - from codeframe.ui import server - - db = server.app.state.db - db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - - # Mock LeadAgent to raise exception when checking discovery status - mock_agent = Mock() - mock_agent.get_discovery_status.side_effect = Exception("Database error") - - # ACT - with patch("codeframe.ui.routers.agents.start_agent") as mock_start_agent: - mock_start_agent.return_value = AsyncMock() - with patch("codeframe.ui.routers.agents.LeadAgent", return_value=mock_agent): - response = test_client_with_db.post(f"/api/projects/{project_id}/start") - - # ASSERT - Should return 202 and proceed with start (fallback behavior) - assert response.status_code == 202 - mock_start_agent.assert_called_once() diff --git a/tests/api/test_chat_api.py b/tests/api/test_chat_api.py deleted file mode 100644 index 3d2d82aa..00000000 --- a/tests/api/test_chat_api.py +++ /dev/null @@ -1,346 +0,0 @@ -""" -Tests for Chat API Endpoints (cf-14.1) - -Test Coverage: -1. POST /api/projects/{id}/chat - Send message and get response -2. GET /api/projects/{id}/chat/history - Retrieve conversation history -3. Error handling: - - 404: Project not found - - 400: Empty message - - 500: Agent communication failure -4. WebSocket broadcasting integration -5. Message persistence - -Test Approach: TDD (RED-GREEN-REFACTOR) -""" - -import pytest -from unittest.mock import Mock, patch, AsyncMock - -from codeframe.core.models import AgentMaturity -from codeframe.ui.shared import running_agents - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -@pytest.fixture -def test_project(api_client): - """Create a test project with running Lead Agent.""" - project_id = get_app().state.db.create_project( - name="Test Chat Project", description="Test Chat Project project" - ) - - # Create Lead Agent record - get_app().state.db.create_agent( - agent_id=f"lead-{project_id}", - agent_type="lead", - provider="anthropic", - maturity_level=AgentMaturity.D4, # Expert level - ) - - return project_id - - -class TestChatEndpoint: - """Test POST /api/projects/{id}/chat endpoint (cf-14.1)""" - - def test_send_message_success(self, api_client, test_project): - """ - RED Test: Send message and get AI response - - Expected behavior: - - Accept user message - - Route to Lead Agent - - Return AI response - - Broadcast via WebSocket - - Store message in database - """ - # Arrange - user_message = "Hello, I want to build a web app" - - # Import the server module to access running_agents - - # Mock Lead Agent - mock_agent = Mock() - mock_agent.chat.return_value = "Hi! Let's discuss your project. What features do you need?" - - # Add mock agent to running_agents dictionary - running_agents[test_project] = mock_agent - - try: - # Mock WebSocket broadcast - with patch( - "codeframe.ui.shared.manager.broadcast", new_callable=AsyncMock - ) as mock_broadcast: - # Act - response = api_client.post( - f"/api/projects/{test_project}/chat", json={"message": user_message} - ) - - # Assert - assert response.status_code == 200 - data = response.json() - - # Check response structure - assert "response" in data - assert "timestamp" in data - assert ( - data["response"] == "Hi! Let's discuss your project. What features do you need?" - ) - - # Verify Lead Agent was called - mock_agent.chat.assert_called_once_with(user_message) - - # Verify WebSocket broadcast was attempted - assert mock_broadcast.called - finally: - # Clean up - running_agents.pop(test_project, None) - - def test_send_message_empty_validation(self, api_client, test_project): - """ - RED Test: Reject empty message with 400 Bad Request - """ - # Act - response = api_client.post(f"/api/projects/{test_project}/chat", json={"message": ""}) - - # Assert - assert response.status_code == 400 - assert "empty" in response.json()["detail"].lower() - - def test_send_message_project_not_found(self, api_client): - """ - RED Test: Return 404 for non-existent project - """ - # Act - response = api_client.post("/api/projects/99999/chat", json={"message": "Hello"}) - - # Assert - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - def test_send_message_agent_not_started(self, api_client): - """ - RED Test: Return 400 if Lead Agent not started for project - """ - # Arrange: Create project without starting agent - project_id = get_app().state.db.create_project( - name="Project Without Agent", description="Project Without Agent project" - ) - - # Act - response = api_client.post(f"/api/projects/{project_id}/chat", json={"message": "Hello"}) - - # Assert - assert response.status_code == 400 - assert "agent not started" in response.json()["detail"].lower() - - def test_send_message_agent_failure(self, api_client, test_project): - """ - RED Test: Handle agent communication failure with 500 - """ - # Arrange - with patch("codeframe.ui.routers.chat.running_agents") as mock_agents: - mock_agent = Mock() - mock_agent.chat.side_effect = Exception("API connection failed") - mock_agents.get.return_value = mock_agent - - # Act - response = api_client.post( - f"/api/projects/{test_project}/chat", json={"message": "Hello"} - ) - - # Assert - assert response.status_code == 500 - assert "error" in response.json()["detail"].lower() - - -class TestChatHistoryEndpoint: - """Test GET /api/projects/{id}/chat/history endpoint (cf-14.1)""" - - def test_get_history_success(self, api_client, test_project): - """ - RED Test: Retrieve conversation history from database - """ - # Arrange: Create conversation history with indexed keys - get_app().state.db.create_memory( - project_id=test_project, category="conversation", key="user_0", value="Hello" - ) - get_app().state.db.create_memory( - project_id=test_project, - category="conversation", - key="assistant_1", - value="Hi! How can I help?", - ) - get_app().state.db.create_memory( - project_id=test_project, - category="conversation", - key="user_2", - value="I want to build an app", - ) - - # Act - response = api_client.get(f"/api/projects/{test_project}/chat/history") - - # Assert - assert response.status_code == 200 - data = response.json() - - assert "messages" in data - assert len(data["messages"]) == 3 - - # Check chronological order - messages = data["messages"] - assert messages[0]["role"] == "user" - assert messages[0]["content"] == "Hello" - assert messages[1]["role"] == "assistant" - assert messages[1]["content"] == "Hi! How can I help?" - assert messages[2]["role"] == "user" - assert messages[2]["content"] == "I want to build an app" - - # Check timestamps exist - for msg in messages: - assert "timestamp" in msg - - def test_get_history_pagination(self, api_client, test_project): - """ - RED Test: Support pagination with limit and offset - """ - # Arrange: Create 10 messages with indexed keys - for i in range(10): - role = "user" if i % 2 == 0 else "assistant" - get_app().state.db.create_memory( - project_id=test_project, category="conversation", key=f"{role}_{i}", value=f"Message {i}" - ) - - # Act: Get first 5 messages - response = api_client.get( - f"/api/projects/{test_project}/chat/history", params={"limit": 5, "offset": 0} - ) - - # Assert - assert response.status_code == 200 - data = response.json() - assert len(data["messages"]) == 5 - assert data["messages"][0]["content"] == "Message 0" - - # Act: Get next 5 messages - response = api_client.get( - f"/api/projects/{test_project}/chat/history", params={"limit": 5, "offset": 5} - ) - - # Assert - assert response.status_code == 200 - data = response.json() - assert len(data["messages"]) == 5 - assert data["messages"][0]["content"] == "Message 5" - - def test_get_history_project_not_found(self, api_client): - """ - RED Test: Return 404 for non-existent project - """ - # Act - response = api_client.get("/api/projects/99999/chat/history") - - # Assert - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - def test_get_history_empty(self, api_client, test_project): - """ - RED Test: Return empty list for project with no conversation - """ - # Act - response = api_client.get(f"/api/projects/{test_project}/chat/history") - - # Assert - assert response.status_code == 200 - data = response.json() - assert "messages" in data - assert data["messages"] == [] - - -class TestChatWebSocketIntegration: - """Test WebSocket broadcasting for chat messages (cf-14.1)""" - - @pytest.mark.asyncio - async def test_chat_broadcasts_message(self, api_client, test_project): - """ - RED Test: Verify chat message broadcasts via WebSocket - """ - # Arrange - - # Mock Lead Agent - mock_agent = Mock() - mock_agent.chat.return_value = "AI response" - - # Add mock agent to running_agents dictionary - running_agents[test_project] = mock_agent - - try: - with patch( - "codeframe.ui.shared.manager.broadcast", new_callable=AsyncMock - ) as mock_broadcast: - # Act - response = api_client.post( - f"/api/projects/{test_project}/chat", json={"message": "Hello"} - ) - - # Assert - assert response.status_code == 200 - - # Verify broadcast was called with correct message structure - assert mock_broadcast.call_count >= 1 - - # Check broadcast message contains chat data - broadcast_call = mock_broadcast.call_args_list[0] - broadcast_message = broadcast_call[0][0] - - assert broadcast_message["type"] == "chat_message" - assert broadcast_message["project_id"] == test_project - assert "role" in broadcast_message - assert "content" in broadcast_message - finally: - # Clean up - running_agents.pop(test_project, None) - - @pytest.mark.asyncio - async def test_chat_continues_when_broadcast_fails(self, api_client, test_project): - """ - Test: Chat continues working even if WebSocket broadcast fails - - Covers edge case where broadcast exception is caught and ignored (line 512-514) - """ - # Arrange - - mock_agent = Mock() - mock_agent.chat.return_value = "Response despite broadcast failure" - running_agents[test_project] = mock_agent - - try: - # Mock broadcast to raise exception - with patch( - "codeframe.ui.shared.manager.broadcast", new_callable=AsyncMock - ) as mock_broadcast: - mock_broadcast.side_effect = Exception("WebSocket connection lost") - - # Act - response = api_client.post( - f"/api/projects/{test_project}/chat", json={"message": "Test message"} - ) - - # Assert - Chat should still work despite broadcast failure - assert response.status_code == 200 - data = response.json() - assert data["response"] == "Response despite broadcast failure" - assert "timestamp" in data - - # Verify broadcast was attempted - assert mock_broadcast.called - finally: - running_agents.pop(test_project, None) diff --git a/tests/api/test_generate_tasks_endpoint.py b/tests/api/test_generate_tasks_endpoint.py deleted file mode 100644 index b6b0a894..00000000 --- a/tests/api/test_generate_tasks_endpoint.py +++ /dev/null @@ -1,250 +0,0 @@ -"""API tests for generate-tasks endpoint (manual task generation). - -Following TDD: These tests are written FIRST before API implementation. -Tests verify POST /api/projects/{id}/discovery/generate-tasks endpoint. - -This endpoint provides manual control over task generation, allowing users -to trigger the existing generate_planning_background() function on demand -when the project is in the planning phase with a completed PRD. -""" - -import os -from unittest.mock import patch - -import pytest - - -def get_db_from_client(api_client): - """Get database instance from test client's app.""" - from codeframe.ui import server - - return server.app.state.db - - -def create_mock_prd(db, project_id: int, content: str = "# Test PRD\n\nThis is a test PRD."): - """Create a mock PRD in the memory table.""" - cursor = db.conn.cursor() - cursor.execute( - """ - INSERT OR REPLACE INTO memory (project_id, category, key, value) - VALUES (?, 'prd', 'content', ?) - """, - (project_id, content), - ) - db.conn.commit() - - -class TestGenerateTasksEndpoint: - """Test POST /api/projects/{id}/discovery/generate-tasks endpoint.""" - - def test_returns_404_for_nonexistent_project(self, api_client): - """Test endpoint returns 404 when project does not exist.""" - # ACT - response = api_client.post("/api/projects/99999/discovery/generate-tasks") - - # ASSERT - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - def test_returns_403_for_unauthorized_user(self, api_client): - """Test endpoint returns 403 when user doesn't have project access.""" - # ARRANGE - # Create another user first (test user is id=1) - db = get_db_from_client(api_client) - cursor = db.conn.cursor() - cursor.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (999, 'other@example.com', 'Other User', '!DISABLED!', 1, 0, 1, 1) - """ - ) - db.conn.commit() - - # Create project by another user (not the test user) - project_id = db.create_project( - "other-user-project", - "Project owned by another user", - user_id=999, # Different user - ) - - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - assert response.status_code == 403 - assert "access denied" in response.json()["detail"].lower() - - def test_returns_400_when_not_in_planning_phase(self, api_client): - """Test endpoint returns 400 when project is not in planning phase.""" - # ARRANGE - db = get_db_from_client(api_client) - project_id = db.create_project("test-project", "Test Project") - # Project starts in discovery phase by default - - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - assert response.status_code == 400 - detail = response.json()["detail"].lower() - assert "planning" in detail or "phase" in detail - - def test_returns_400_when_prd_not_generated(self, api_client): - """Test endpoint returns 400 when PRD does not exist.""" - # ARRANGE - db = get_db_from_client(api_client) - project_id = db.create_project("test-project", "Test Project") - # Update to planning phase but don't create PRD - db.update_project(project_id, {"phase": "planning"}) - - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - assert response.status_code == 400 - detail = response.json()["detail"].lower() - assert "prd" in detail - - @patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False) - def test_returns_500_when_api_key_missing(self, api_client): - """Test endpoint returns 500 when ANTHROPIC_API_KEY is not set.""" - # ARRANGE - db = get_db_from_client(api_client) - project_id = db.create_project("test-project", "Test Project") - db.update_project(project_id, {"phase": "planning"}) - # Create a mock PRD - create_mock_prd(db, project_id) - - # Temporarily clear the API key for this test - original_key = os.environ.get("ANTHROPIC_API_KEY") - os.environ.pop("ANTHROPIC_API_KEY", None) - - try: - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - assert response.status_code == 500 - detail = response.json()["detail"].lower() - assert "api" in detail or "key" in detail - finally: - # Restore the API key - if original_key: - os.environ["ANTHROPIC_API_KEY"] = original_key - else: - os.environ["ANTHROPIC_API_KEY"] = "test-key" - - @patch("codeframe.ui.routers.discovery.generate_planning_background") - def test_returns_200_and_triggers_background_task( - self, mock_generate_planning, api_client - ): - """Test endpoint returns 200 and triggers background task when valid.""" - # ARRANGE - db = get_db_from_client(api_client) - project_id = db.create_project("test-project", "Test Project") - db.update_project(project_id, {"phase": "planning"}) - # Create a mock PRD - create_mock_prd(db, project_id) - - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert "message" in data - assert "task" in data["message"].lower() or "started" in data["message"].lower() - - @patch("codeframe.ui.routers.discovery.generate_planning_background") - def test_background_task_receives_correct_parameters( - self, mock_generate_planning, api_client - ): - """Test that background task is called with correct project_id, db, and api_key.""" - # ARRANGE - db = get_db_from_client(api_client) - project_id = db.create_project("test-project", "Test Project") - db.update_project(project_id, {"phase": "planning"}) - create_mock_prd(db, project_id) - - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - assert response.status_code == 200 - # The background task should be scheduled (checked via BackgroundTasks) - # Note: In TestClient, background tasks are executed synchronously - # so we can verify the function was called correctly - - def test_returns_200_with_flag_when_tasks_already_exist(self, api_client): - """Test endpoint is idempotent - returns 200 with tasks_already_exist flag. - - Instead of returning 400 error when tasks exist, the endpoint should - be idempotent and return success with a flag indicating tasks already - exist. This improves UX for users who join late and miss WebSocket events. - """ - # ARRANGE - db = get_db_from_client(api_client) - project_id = db.create_project("test-project", "Test Project") - db.update_project(project_id, {"phase": "planning"}) - create_mock_prd(db, project_id) - - # Create some existing tasks to simulate already-generated tasks - # Insert directly into tasks table (simpler than using Task model) - cursor = db.conn.cursor() - cursor.execute( - """ - INSERT INTO tasks (project_id, title, description, status, priority, workflow_step) - VALUES (?, 'Task 1', 'Task 1 description', 'pending', 2, 1) - """, - (project_id,), - ) - db.conn.commit() - - # ACT - response = api_client.post( - f"/api/projects/{project_id}/discovery/generate-tasks" - ) - - # ASSERT - idempotent endpoint returns 200 with flag - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert data["tasks_already_exist"] is True - assert "already" in data["message"].lower() - - -class TestGenerateTasksEndpointIntegration: - """Integration tests for generate-tasks endpoint.""" - - @pytest.mark.asyncio - async def test_generate_planning_background_is_async_compatible(self): - """Test that generate_planning_background can be used as a background task.""" - from codeframe.ui.routers.discovery import generate_planning_background - import inspect - - # Verify the function is async - assert inspect.iscoroutinefunction(generate_planning_background) - - # Verify the function signature has required parameters - sig = inspect.signature(generate_planning_background) - params = list(sig.parameters.keys()) - - assert "project_id" in params - assert "db" in params - assert "api_key" in params diff --git a/tests/api/test_schedule_api.py b/tests/api/test_schedule_api.py deleted file mode 100644 index d793bfa9..00000000 --- a/tests/api/test_schedule_api.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for Schedule API endpoints. - -TDD tests for the schedule API: -- GET /api/schedule/{project_id} - Get project schedule -- GET /api/schedule/{project_id}/predict - Predict completion -- GET /api/schedule/{project_id}/bottlenecks - Identify bottlenecks -""" - -import pytest - -pytestmark = pytest.mark.v2 - - -@pytest.mark.unit -class TestScheduleAPI: - """Test schedule API endpoints.""" - - def test_schedule_router_imports(self): - """Test schedule router can be imported.""" - from codeframe.ui.routers.schedule import router - - assert router is not None - assert router.prefix == "/api/schedule" - - def test_schedule_response_models_exist(self): - """Test response models are defined.""" - from codeframe.ui.routers.schedule import ( - TaskAssignmentResponse, - ScheduleResponse, - CompletionPredictionResponse, - BottleneckResponse, - ) - - assert TaskAssignmentResponse is not None - assert ScheduleResponse is not None - assert CompletionPredictionResponse is not None - assert BottleneckResponse is not None - - -@pytest.mark.unit -class TestScheduleEndpoints: - """Test schedule endpoint functions exist.""" - - def test_get_project_schedule_exists(self): - """Test get_project_schedule endpoint function exists.""" - from codeframe.ui.routers.schedule import get_project_schedule - - assert callable(get_project_schedule) - - def test_predict_completion_exists(self): - """Test predict_completion endpoint function exists.""" - from codeframe.ui.routers.schedule import predict_completion - - assert callable(predict_completion) - - def test_get_bottlenecks_exists(self): - """Test get_bottlenecks endpoint function exists.""" - from codeframe.ui.routers.schedule import get_bottlenecks - - assert callable(get_bottlenecks) diff --git a/tests/api/test_templates_api.py b/tests/api/test_templates_api.py deleted file mode 100644 index f0c11743..00000000 --- a/tests/api/test_templates_api.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for Templates API endpoints. - -TDD tests for the templates API: -- GET /api/templates/ - List templates -- GET /api/templates/categories - List categories -- GET /api/templates/{template_id} - Get template details -- POST /api/templates/{project_id}/apply - Apply template -""" - -import pytest - -pytestmark = pytest.mark.v2 - - -@pytest.mark.unit -class TestTemplatesAPI: - """Test templates API endpoints.""" - - def test_templates_router_imports(self): - """Test templates router can be imported.""" - from codeframe.ui.routers.templates import router - - assert router is not None - assert router.prefix == "/api/templates" - - def test_templates_response_models_exist(self): - """Test response models are defined.""" - from codeframe.ui.routers.templates import ( - TemplateTaskResponse, - TemplateResponse, - TemplateListResponse, - ApplyTemplateRequest, - ApplyTemplateResponse, - CategoryListResponse, - ) - - assert TemplateTaskResponse is not None - assert TemplateResponse is not None - assert TemplateListResponse is not None - assert ApplyTemplateRequest is not None - assert ApplyTemplateResponse is not None - assert CategoryListResponse is not None - - -@pytest.mark.unit -class TestTemplatesEndpoints: - """Test templates endpoint functions exist.""" - - def test_list_templates_exists(self): - """Test list_templates endpoint function exists.""" - from codeframe.ui.routers.templates import list_templates - - assert callable(list_templates) - - def test_list_categories_exists(self): - """Test list_categories endpoint function exists.""" - from codeframe.ui.routers.templates import list_categories - - assert callable(list_categories) - - def test_get_template_exists(self): - """Test get_template endpoint function exists.""" - from codeframe.ui.routers.templates import get_template - - assert callable(get_template) - - def test_apply_template_exists(self): - """Test apply_template endpoint function exists.""" - from codeframe.ui.routers.templates import apply_template - - assert callable(apply_template) diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index a604cbf7..4a358b78 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -25,15 +25,9 @@ # These tests rely on v1 routers/persistence that use get_db/get_db_websocket # NOTE: collect_ignore must be at module level but can come after imports collect_ignore = [ - "test_websocket_router.py", - "test_websocket_proactive.py", "test_websocket_broadcasts.py", "test_websocket_integration.py", "test_websocket_subscriptions.py", - "test_discovery_automation.py", - "test_task_approval.py", - "test_task_approval_execution.py", - "test_assign_pending_tasks.py", "test_deployment_mode.py", "test_project_api.py", "test_session_router.py", diff --git a/tests/ui/test_assign_pending_tasks.py b/tests/ui/test_assign_pending_tasks.py deleted file mode 100644 index c5f13eda..00000000 --- a/tests/ui/test_assign_pending_tasks.py +++ /dev/null @@ -1,392 +0,0 @@ -""" -Tests for assign pending tasks endpoint (Issue #248 fix). - -This module tests the POST /api/projects/{project_id}/tasks/assign endpoint -that allows users to manually trigger task assignment for stuck pending tasks. -""" - -import os -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from fastapi import BackgroundTasks, HTTPException, Request - -from codeframe.core.models import Task, TaskStatus - - -@pytest.fixture -def mock_request(): - """Create mock starlette Request for rate limiter.""" - request = MagicMock(spec=Request) - request.client = MagicMock() - request.client.host = "127.0.0.1" - request.headers = {} - request.state = MagicMock() - request.state.user = None - return request - - -@pytest.fixture -def mock_background_tasks(monkeypatch): - """Create mock BackgroundTasks. - - Also clears ANTHROPIC_API_KEY to make tests deterministic. - Tests that need API key behavior should explicitly set it. - """ - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - bg = MagicMock(spec=BackgroundTasks) - bg.add_task = MagicMock() - return bg - - -@pytest.fixture -def mock_db(): - """Create mock Database with project in active phase.""" - db = MagicMock() - db.get_project.return_value = { - "id": 1, - "name": "Test Project", - "phase": "active" # Must be in active phase to assign tasks - } - db.user_has_project_access.return_value = True - return db - - -@pytest.fixture -def mock_user(): - """Create mock authenticated user.""" - user = MagicMock() - user.id = 1 - user.email = "test@example.com" - return user - - -@pytest.fixture -def mock_manager(): - """Create mock ConnectionManager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - -class TestAssignPendingTasksEndpoint: - """Tests for POST /api/projects/{project_id}/tasks/assign.""" - - @pytest.mark.asyncio - async def test_assign_pending_tasks_with_pending_tasks( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that endpoint triggers execution when pending tasks exist.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: 2 pending unassigned tasks - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING, assigned_to=None), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING, assigned_to=None), - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.COMPLETED, assigned_to="agent-1"), - ] - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert response.success is True - assert response.pending_count == 2 - assert "2" in response.message - mock_background_tasks.add_task.assert_called_once() - - @pytest.mark.asyncio - async def test_assign_pending_tasks_no_pending_tasks( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that endpoint returns success but doesn't trigger execution when no pending tasks.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: No pending tasks - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.COMPLETED, assigned_to="agent-1"), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.IN_PROGRESS, assigned_to="agent-2"), - ] - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert response.success is True - assert response.pending_count == 0 - assert "no pending" in response.message.lower() - mock_background_tasks.add_task.assert_not_called() - - @pytest.mark.asyncio - async def test_assign_pending_tasks_wrong_phase( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that endpoint returns 400 when project is not in active phase.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: Project in planning phase - mock_db.get_project.return_value = { - "id": 1, - "name": "Test Project", - "phase": "planning" - } - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 400 - assert "active" in exc_info.value.detail.lower() - - @pytest.mark.asyncio - async def test_assign_pending_tasks_project_not_found( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that endpoint returns 404 when project doesn't exist.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - mock_db.get_project.return_value = None - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await assign_pending_tasks( - request=mock_request, - project_id=999, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 404 - - @pytest.mark.asyncio - async def test_assign_pending_tasks_access_denied( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that endpoint returns 403 when user doesn't have access.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - mock_db.user_has_project_access.return_value = False - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 403 - - @pytest.mark.asyncio - async def test_assign_pending_tasks_without_api_key( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request, caplog - ): - """Test that endpoint warns when API key is missing.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - import logging - - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING, assigned_to=None), - ] - - # Ensure no API key - env_without_key = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"} - - with caplog.at_level(logging.WARNING): - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, env_without_key, clear=True): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Should return failure when API key missing - assert response.success is False - assert response.pending_count == 1 - assert "api key" in response.message.lower() or "not configured" in response.message.lower() - - # Background task should NOT be scheduled - mock_background_tasks.add_task.assert_not_called() - - # Should log warning - assert any("ANTHROPIC_API_KEY" in record.message for record in caplog.records) - - @pytest.mark.asyncio - async def test_assign_pending_tasks_only_counts_unassigned( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that only pending AND unassigned tasks are counted.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: Mix of tasks - only 1 is pending AND unassigned - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING, assigned_to=None), # Count this - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING, assigned_to="agent-1"), # Already assigned - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.IN_PROGRESS, assigned_to=None), # Not pending - ] - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Should not trigger because there's an in_progress task - assert response.pending_count == 1 - assert "in progress" in response.message.lower() - mock_background_tasks.add_task.assert_not_called() - - @pytest.mark.asyncio - async def test_assign_pending_tasks_blocked_when_execution_in_progress( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that assignment is blocked when tasks are already in progress.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: 2 pending tasks + 1 in progress - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING, assigned_to=None), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING, assigned_to=None), - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.IN_PROGRESS, assigned_to="agent-1"), - ] - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Should return success but NOT schedule execution - assert response.success is True - assert response.pending_count == 2 - assert "in progress" in response.message.lower() - assert "1 task" in response.message.lower() # Reports 1 task running - mock_background_tasks.add_task.assert_not_called() - - @pytest.mark.asyncio - async def test_assign_pending_tasks_allowed_when_no_execution_in_progress( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that assignment proceeds when no tasks are in progress.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: Only pending and completed tasks, no in_progress or assigned - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING, assigned_to=None), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING, assigned_to=None), - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.COMPLETED, assigned_to="agent-1"), - ] - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Should schedule execution - assert response.success is True - assert response.pending_count == 2 - assert "started" in response.message.lower() - mock_background_tasks.add_task.assert_called_once() - - @pytest.mark.asyncio - async def test_assign_pending_tasks_blocked_when_tasks_assigned( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that assignment is blocked when tasks are in ASSIGNED status.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - - # Setup: 2 pending tasks + 1 assigned (not yet in_progress) - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING, assigned_to=None), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING, assigned_to=None), - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.ASSIGNED, assigned_to="agent-1"), - ] - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await assign_pending_tasks( - request=mock_request, - project_id=1, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Should return success but NOT schedule execution - assert response.success is True - assert response.pending_count == 2 - assert "in progress" in response.message.lower() or "assigned" in response.message.lower() - mock_background_tasks.add_task.assert_not_called() - - -class TestAssignPendingTasksResponseModel: - """Tests for TaskAssignmentResponse model.""" - - def test_response_model_structure(self): - """Test that response model has correct fields.""" - from codeframe.ui.routers.tasks import TaskAssignmentResponse - - response = TaskAssignmentResponse( - success=True, - pending_count=5, - message="Test message" - ) - - assert response.success is True - assert response.pending_count == 5 - assert response.message == "Test message" - - -class TestAssignPendingTasksFunctionSignature: - """Tests to ensure endpoint signature is correct.""" - - def test_endpoint_accepts_required_parameters(self): - """Test that endpoint function accepts required parameters.""" - from codeframe.ui.routers.tasks import assign_pending_tasks - import inspect - - sig = inspect.signature(assign_pending_tasks) - params = list(sig.parameters.keys()) - - assert "request" in params # Required for rate limiting - assert "project_id" in params - assert "background_tasks" in params - assert "db" in params - assert "current_user" in params diff --git a/tests/ui/test_discovery_automation.py b/tests/ui/test_discovery_automation.py deleted file mode 100644 index 84a3f772..00000000 --- a/tests/ui/test_discovery_automation.py +++ /dev/null @@ -1,362 +0,0 @@ -""" -Tests for planning automation triggered after PRD generation (Feature: 016-planning-phase-automation). - -These tests verify: -- Planning automation is triggered after PRD completion -- WebSocket events are broadcast at each stage -- Error handling and retry capability -- Proper sequencing of generate_issues and decompose_prd -""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from codeframe.ui.routers.discovery import generate_planning_background - - -@pytest.fixture -def mock_manager(): - """Create mock ConnectionManager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - -@pytest.fixture -def mock_db(): - """Create mock Database.""" - db = MagicMock() - db.get_project.return_value = {"id": 1, "name": "Test Project", "phase": "planning"} - return db - - -@pytest.fixture -def mock_lead_agent(): - """Create mock LeadAgent that returns successful results.""" - agent = MagicMock() - # Mock generate_issues to return a list of issues - agent.generate_issues.return_value = [ - MagicMock(id=1, title="Issue 1"), - MagicMock(id=2, title="Issue 2"), - MagicMock(id=3, title="Issue 3"), - ] - # Mock decompose_prd to return task count info - agent.decompose_prd.return_value = { - "issues": 3, - "tasks": 10, - "success": True, - } - return agent - - -class TestPlanningAutomationBackgroundTask: - """Tests for generate_planning_background function.""" - - @pytest.mark.asyncio - async def test_planning_automation_broadcasts_started_event( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that planning_started event is broadcast when automation begins.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - # Find the planning_started broadcast call - calls = mock_manager.broadcast.call_args_list - planning_started_calls = [ - call for call in calls - if call[0][0].get("type") == "planning_started" - ] - - assert len(planning_started_calls) == 1 - message = planning_started_calls[0][0][0] - assert message["type"] == "planning_started" - assert message["project_id"] == 1 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_planning_automation_calls_generate_issues( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that generate_issues is called with sprint_number=1.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - mock_lead_agent.generate_issues.assert_called_once_with(sprint_number=1) - - @pytest.mark.asyncio - async def test_planning_automation_broadcasts_issues_generated_event( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that issues_generated event is broadcast with issue count.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - issues_generated_calls = [ - call for call in calls - if call[0][0].get("type") == "issues_generated" - ] - - assert len(issues_generated_calls) == 1 - message = issues_generated_calls[0][0][0] - assert message["type"] == "issues_generated" - assert message["project_id"] == 1 - assert message["issue_count"] == 3 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_planning_automation_calls_decompose_prd( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that decompose_prd is called after generate_issues.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - mock_lead_agent.decompose_prd.assert_called_once() - - @pytest.mark.asyncio - async def test_planning_automation_broadcasts_tasks_decomposed_event( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that tasks_decomposed event is broadcast with task count.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - tasks_decomposed_calls = [ - call for call in calls - if call[0][0].get("type") == "tasks_decomposed" - ] - - assert len(tasks_decomposed_calls) == 1 - message = tasks_decomposed_calls[0][0][0] - assert message["type"] == "tasks_decomposed" - assert message["project_id"] == 1 - assert message["task_count"] == 10 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_planning_automation_broadcasts_tasks_ready_event( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that tasks_ready event is broadcast when automation completes.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - tasks_ready_calls = [ - call for call in calls - if call[0][0].get("type") == "tasks_ready" - ] - - assert len(tasks_ready_calls) == 1 - message = tasks_ready_calls[0][0][0] - assert message["type"] == "tasks_ready" - assert message["project_id"] == 1 - assert message["total_tasks"] == 10 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_planning_automation_all_events_in_order( - self, mock_db, mock_lead_agent, mock_manager - ): - """Test that all WebSocket events are broadcast in correct order.""" - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - event_types = [call[0][0].get("type") for call in calls] - - # Verify order: planning_started → issues_generated → tasks_decomposed → tasks_ready - expected_order = ["planning_started", "issues_generated", "tasks_decomposed", "tasks_ready"] - assert event_types == expected_order - - -class TestPlanningAutomationErrorHandling: - """Tests for error handling in planning automation.""" - - @pytest.mark.asyncio - async def test_issues_generation_error_broadcasts_failure( - self, mock_db, mock_manager - ): - """Test that planning_failed event is broadcast when generate_issues fails.""" - mock_agent = MagicMock() - mock_agent.generate_issues.side_effect = Exception("API Error") - - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - failure_calls = [ - call for call in calls - if call[0][0].get("type") == "planning_failed" - ] - - assert len(failure_calls) == 1 - message = failure_calls[0][0][0] - assert message["type"] == "planning_failed" - assert message["project_id"] == 1 - assert "error" in message - assert "API Error" in message["error"] - - @pytest.mark.asyncio - async def test_decompose_prd_error_broadcasts_failure( - self, mock_db, mock_manager - ): - """Test that planning_failed event is broadcast when decompose_prd fails.""" - mock_agent = MagicMock() - mock_agent.generate_issues.return_value = [MagicMock(id=1)] - mock_agent.decompose_prd.side_effect = Exception("Task decomposition failed") - - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - failure_calls = [ - call for call in calls - if call[0][0].get("type") == "planning_failed" - ] - - assert len(failure_calls) == 1 - message = failure_calls[0][0][0] - assert message["type"] == "planning_failed" - assert "Task decomposition failed" in message["error"] - - @pytest.mark.asyncio - async def test_error_does_not_update_project_phase( - self, mock_db, mock_manager - ): - """Test that project phase is not updated when planning fails.""" - mock_agent = MagicMock() - mock_agent.generate_issues.side_effect = Exception("API Error") - - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - # Phase should not be updated on error - mock_db.update_project.assert_not_called() - - @pytest.mark.asyncio - async def test_generate_issues_timeout_broadcasts_failure( - self, mock_db, mock_manager - ): - """Test that timeout during generate_issues broadcasts planning_failed.""" - import asyncio - - mock_agent = MagicMock() - # Simulate a slow operation that will timeout - async def slow_generate_issues(*args, **kwargs): - await asyncio.sleep(10) # Longer than timeout - return [] - - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent), \ - patch("codeframe.ui.routers.discovery.asyncio.to_thread", side_effect=slow_generate_issues), \ - patch.dict("os.environ", {"PLANNING_OPERATION_TIMEOUT": "0.1"}): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - failure_calls = [ - call for call in calls - if call[0][0].get("type") == "planning_failed" - ] - - assert len(failure_calls) == 1 - message = failure_calls[0][0][0] - assert message["type"] == "planning_failed" - assert "timed out" in message["error"].lower() - - @pytest.mark.asyncio - async def test_decompose_prd_timeout_broadcasts_failure( - self, mock_db, mock_manager - ): - """Test that timeout during decompose_prd broadcasts planning_failed.""" - import asyncio - - mock_agent = MagicMock() - mock_agent.generate_issues.return_value = [MagicMock(id=1)] - - call_count = 0 - - async def conditional_slow(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - # First call (generate_issues) returns quickly - return [MagicMock(id=1)] - else: - # Second call (decompose_prd) times out - await asyncio.sleep(10) - return {} - - with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ - patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent), \ - patch("codeframe.ui.routers.discovery.asyncio.to_thread", side_effect=conditional_slow), \ - patch.dict("os.environ", {"PLANNING_OPERATION_TIMEOUT": "0.1"}): - await generate_planning_background( - project_id=1, db=mock_db, api_key="test-key" - ) - - calls = mock_manager.broadcast.call_args_list - failure_calls = [ - call for call in calls - if call[0][0].get("type") == "planning_failed" - ] - - assert len(failure_calls) == 1 - message = failure_calls[0][0][0] - assert "timed out" in message["error"].lower() - - -class TestPRDCompletionTrigger: - """Tests for planning automation trigger after PRD completion.""" - - @pytest.mark.asyncio - async def test_prd_completion_triggers_planning_automation(self): - """Test that planning automation is triggered after PRD completion.""" - # This test verifies the integration point in generate_prd_background - # We'll test that the function signature supports BackgroundTasks - from codeframe.ui.routers.discovery import generate_prd_background - import inspect - - # Check that the function can accept background_tasks parameter - # (This will be added as part of the implementation) - sig = inspect.signature(generate_prd_background) - params = list(sig.parameters.keys()) - - # The function should have project_id, db, api_key parameters - assert "project_id" in params - assert "db" in params - assert "api_key" in params diff --git a/tests/ui/test_task_approval.py b/tests/ui/test_task_approval.py deleted file mode 100644 index ce381e19..00000000 --- a/tests/ui/test_task_approval.py +++ /dev/null @@ -1,729 +0,0 @@ -""" -Tests for task approval endpoint (Feature: 016-planning-phase-automation). - -These tests verify: -- Task approval transitions project to development phase -- Approved tasks are updated to pending status -- Excluded tasks remain unchanged -- Validation errors for wrong phase -- WebSocket events are broadcast -""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from fastapi import BackgroundTasks, Request - -from codeframe.core.models import Task, TaskStatus -from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - - -@pytest.fixture -def mock_request(): - """Create mock starlette Request for rate limiter.""" - request = MagicMock(spec=Request) - request.client = MagicMock() - request.client.host = "127.0.0.1" - request.headers = {} - request.state = MagicMock() - request.state.user = None - return request - - -@pytest.fixture -def mock_background_tasks(monkeypatch): - """Create mock BackgroundTasks. - - Also clears ANTHROPIC_API_KEY to make tests deterministic. - Tests that need API key behavior should explicitly set it. - """ - # Clear ANTHROPIC_API_KEY to ensure deterministic behavior - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - - bg = MagicMock(spec=BackgroundTasks) - bg.add_task = MagicMock() - return bg - - -@pytest.fixture -def mock_db(): - """Create mock Database with project and tasks.""" - db = MagicMock() - db.get_project.return_value = { - "id": 1, - "name": "Test Project", - "phase": "planning" - } - db.user_has_project_access.return_value = True - - # Mock tasks (using PENDING status which is valid for planning phase) - mock_tasks = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.PENDING), - ] - db.get_project_tasks.return_value = mock_tasks - db.update_task.return_value = None - db.update_project.return_value = None - - return db - - -@pytest.fixture -def mock_user(): - """Create mock authenticated user.""" - user = MagicMock() - user.id = 1 - user.email = "test@example.com" - return user - - -@pytest.fixture -def mock_manager(): - """Create mock ConnectionManager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - -class TestTaskApprovalEndpoint: - """Tests for POST /api/projects/{project_id}/tasks/approve.""" - - @pytest.mark.asyncio - async def test_approve_tasks_returns_success_response( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that approving tasks returns success response with summary.""" - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert response.success is True - assert response.phase == "active" - assert response.approved_count == 3 - assert response.excluded_count == 0 - - @pytest.mark.asyncio - async def test_approve_tasks_with_exclusions( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that excluded tasks are not approved.""" - body = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert response.approved_count == 1 - assert response.excluded_count == 2 - - @pytest.mark.asyncio - async def test_approve_tasks_updates_task_status_to_pending( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that approved tasks are updated to pending status.""" - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"): - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Verify update_task was called for each task - assert mock_db.update_task.call_count == 3 - # Verify each call updates status to pending - for call in mock_db.update_task.call_args_list: - task_id, updates = call[0] - assert updates.get("status") == "pending" - - @pytest.mark.asyncio - async def test_approve_tasks_transitions_phase_to_active( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that project phase is transitioned to active.""" - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager") as mock_phase_manager: - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Verify PhaseManager.transition was called - mock_phase_manager.transition.assert_called_once_with(1, "active", mock_db) - - @pytest.mark.asyncio - async def test_approve_tasks_broadcasts_development_started( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that development_started event is broadcast.""" - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"): - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Find the development_started broadcast - calls = mock_manager.broadcast.call_args_list - development_started_calls = [ - call for call in calls - if call[0][0].get("type") == "development_started" - ] - - assert len(development_started_calls) == 1 - message = development_started_calls[0][0][0] - assert message["type"] == "development_started" - assert message["project_id"] == 1 - assert message["approved_count"] == 3 - assert message["excluded_count"] == 0 - - @pytest.mark.asyncio - async def test_reject_tasks_returns_rejection_message( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that rejecting tasks returns rejection response.""" - body = TaskApprovalRequest(approved=False, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert response.success is False - assert "not approved" in response.message.lower() - - -class TestTaskApprovalValidation: - """Tests for task approval validation.""" - - @pytest.mark.asyncio - async def test_approve_tasks_wrong_phase_returns_400( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that approving tasks in wrong phase returns 400.""" - from fastapi import HTTPException - - mock_db.get_project.return_value = { - "id": 1, - "name": "Test Project", - "phase": "discovery" # Wrong phase - } - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 400 - assert "planning" in exc_info.value.detail.lower() - - @pytest.mark.asyncio - async def test_approve_tasks_no_tasks_returns_404( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that approving with no tasks returns 404.""" - from fastapi import HTTPException - - mock_db.get_project_tasks.return_value = [] # No tasks - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 404 - assert "no tasks" in exc_info.value.detail.lower() - - @pytest.mark.asyncio - async def test_approve_tasks_project_not_found_returns_404( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that approving for non-existent project returns 404.""" - from fastapi import HTTPException - - mock_db.get_project.return_value = None - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await approve_tasks( - request=mock_request, - project_id=999, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 404 - - @pytest.mark.asyncio - async def test_approve_tasks_access_denied_returns_403( - self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that approving without access returns 403.""" - from fastapi import HTTPException - - mock_db.user_has_project_access.return_value = False - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - assert exc_info.value.status_code == 403 - - -class TestWebSocketBroadcastForDevelopmentStarted: - """Tests for broadcast_development_started function.""" - - @pytest.mark.asyncio - async def test_broadcast_development_started_message_format(self): - """Test that development_started message has correct format.""" - from codeframe.ui.websocket_broadcasts import broadcast_development_started - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_development_started( - manager=mock_manager, - project_id=1, - approved_count=5, - excluded_count=2 - ) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "development_started" - assert message["project_id"] == 1 - assert message["approved_count"] == 5 - assert message["excluded_count"] == 2 - assert "timestamp" in message - # Verify timestamp format ends with 'Z' - assert message["timestamp"].endswith("Z") - - -class TestPlanningBroadcastFunctions: - """Tests for planning-related WebSocket broadcast functions.""" - - @pytest.mark.asyncio - async def test_broadcast_planning_started(self): - """Test planning_started broadcast.""" - from codeframe.ui.websocket_broadcasts import broadcast_planning_started - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_planning_started(manager=mock_manager, project_id=1) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "planning_started" - assert message["project_id"] == 1 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_broadcast_issues_generated(self): - """Test issues_generated broadcast.""" - from codeframe.ui.websocket_broadcasts import broadcast_issues_generated - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_issues_generated( - manager=mock_manager, project_id=1, issue_count=5 - ) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "issues_generated" - assert message["project_id"] == 1 - assert message["issue_count"] == 5 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_broadcast_tasks_decomposed(self): - """Test tasks_decomposed broadcast.""" - from codeframe.ui.websocket_broadcasts import broadcast_tasks_decomposed - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_tasks_decomposed( - manager=mock_manager, project_id=1, task_count=10 - ) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "tasks_decomposed" - assert message["project_id"] == 1 - assert message["task_count"] == 10 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_broadcast_tasks_ready(self): - """Test tasks_ready broadcast.""" - from codeframe.ui.websocket_broadcasts import broadcast_tasks_ready - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_tasks_ready( - manager=mock_manager, project_id=1, total_tasks=10 - ) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "tasks_ready" - assert message["project_id"] == 1 - assert message["total_tasks"] == 10 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_broadcast_planning_failed(self): - """Test planning_failed broadcast.""" - from codeframe.ui.websocket_broadcasts import broadcast_planning_failed - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_planning_failed( - manager=mock_manager, project_id=1, error="API Error" - ) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "planning_failed" - assert message["project_id"] == 1 - assert message["error"] == "API Error" - assert message["status"] == "failed" - assert "timestamp" in message - - -# ============================================================================ -# Integration and Edge Case Tests -# ============================================================================ - - -class TestPlanningAutomationIntegration: - """Integration tests for the complete planning automation flow.""" - - @pytest.fixture - def mock_db_with_state(self): - """Create mock Database that tracks state changes.""" - db = MagicMock() - # Track project phase changes - db._project_phase = "planning" - db._tasks = [] - - def get_project(project_id): - return { - "id": project_id, - "name": "Test Project", - "phase": db._project_phase - } - - def update_project(project_id, updates): - if "phase" in updates: - db._project_phase = updates["phase"] - - def get_project_tasks(project_id): - return db._tasks - - def update_task(task_id, updates): - for task in db._tasks: - if task.id == task_id: - if "status" in updates: - task.status = TaskStatus(updates["status"]) - - db.get_project.side_effect = get_project - db.update_project.side_effect = update_project - db.get_project_tasks.side_effect = get_project_tasks - db.update_task.side_effect = update_task - db.user_has_project_access.return_value = True - - return db - - @pytest.mark.asyncio - async def test_end_to_end_planning_to_approval_flow( - self, mock_db_with_state, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test complete flow: planning phase → task approval → development phase.""" - # Setup: Create tasks as if generated by planning automation - mock_db_with_state._tasks = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), - ] - - # Verify starting state - assert mock_db_with_state._project_phase == "planning" - - # Execute approval - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager") as mock_pm: - # Simulate phase manager updating state - def transition_side_effect(pid, phase, db): - db._project_phase = phase - mock_pm.transition.side_effect = transition_side_effect - - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db_with_state, - current_user=mock_user - ) - - # Verify end state - assert response.success is True - assert response.phase == "active" - assert response.approved_count == 2 - - # Verify WebSocket notification was sent - broadcast_calls = [ - call for call in mock_manager.broadcast.call_args_list - if call[0][0].get("type") == "development_started" - ] - assert len(broadcast_calls) == 1 - - @pytest.mark.asyncio - async def test_approval_with_tasks_modified_during_review( - self, mock_db_with_state, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test approval when tasks are modified between generation and approval. - - Scenario: Tasks were generated, user reviews them, but meanwhile - some tasks are deleted or modified by another process. - """ - # Setup: Tasks exist initially - original_tasks = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), - Task(id=3, project_id=1, title="Task 3", status=TaskStatus.PENDING), - ] - mock_db_with_state._tasks = original_tasks.copy() - - # User tries to exclude task 2 and 3, but task 3 was deleted - # Simulate: task 3 no longer exists - mock_db_with_state._tasks = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), - # Task 3 was deleted - ] - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db_with_state, - current_user=mock_user - ) - - # Should still work - task 3 in exclusion list doesn't exist, which is fine - assert response.success is True - assert response.approved_count == 1 # Only task 1 approved - assert response.excluded_count == 1 # Only task 2 excluded (task 3 doesn't exist) - - -class TestConcurrentApprovalAttempts: - """Tests for race condition handling in task approval.""" - - @pytest.mark.asyncio - async def test_double_approval_second_fails(self, mock_db, mock_user, mock_manager, mock_background_tasks, mock_request): - """Test that approving already-approved project fails gracefully. - - Scenario: Two users try to approve at the same time. First succeeds, - second should fail because project is no longer in planning phase. - """ - from fastapi import HTTPException - - # First approval changes phase to active - def get_project_after_first_approval(project_id): - # Simulate state after first approval - return { - "id": project_id, - "name": "Test Project", - "phase": "active" # Already transitioned - } - - mock_db.get_project.side_effect = get_project_after_first_approval - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - pytest.raises(HTTPException) as exc_info: - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Should fail with 400 - wrong phase - assert exc_info.value.status_code == 400 - assert "planning" in exc_info.value.detail.lower() - - @pytest.mark.asyncio - async def test_phase_transition_failure_leaves_tasks_unchanged( - self, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test that if phase transition fails, tasks are not modified. - - This verifies the transaction ordering fix - phase transition - happens before task updates. - """ - from fastapi import HTTPException - - mock_db = MagicMock() - mock_db.get_project.return_value = { - "id": 1, "name": "Test", "phase": "planning" - } - mock_db.user_has_project_access.return_value = True - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - ] - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager") as mock_pm: - # Simulate phase transition failure - mock_pm.transition.side_effect = HTTPException( - status_code=400, detail="Invalid transition" - ) - - with pytest.raises(HTTPException): - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Critical: update_task should NOT have been called since phase transition failed first - mock_db.update_task.assert_not_called() - - @pytest.mark.asyncio - async def test_tasks_deleted_between_fetch_and_update( - self, mock_user, mock_manager, mock_background_tasks, mock_request - ): - """Test handling when tasks are deleted during approval process. - - Scenario: Tasks are fetched, but before update_task is called, - the task is deleted by another process. - """ - mock_db = MagicMock() - mock_db.get_project.return_value = { - "id": 1, "name": "Test", "phase": "planning" - } - mock_db.user_has_project_access.return_value = True - mock_db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), - ] - - # Simulate update_task failing for task 2 (deleted) - def update_task_with_deletion(task_id, updates): - if task_id == 2: - raise Exception("Task not found") # Simulates deletion - return None - - mock_db.update_task.side_effect = update_task_with_deletion - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"): - # Currently the implementation doesn't handle this - it would raise - # This test documents the current behavior - with pytest.raises(Exception, match="Task not found"): - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) diff --git a/tests/ui/test_task_approval_execution.py b/tests/ui/test_task_approval_execution.py deleted file mode 100644 index 18f2af90..00000000 --- a/tests/ui/test_task_approval_execution.py +++ /dev/null @@ -1,565 +0,0 @@ -""" -Tests for multi-agent execution trigger after task approval. - -This module tests the P0 blocker fix: connecting task approval to the -multi-agent execution engine. After tasks are approved, the system should: -1. Schedule a background task to start multi-agent execution -2. Create LeadAgent and call start_multi_agent_execution() -3. Broadcast agent_created and task_assigned events -4. Handle errors gracefully - -Feature: Connect Task Approval to Multi-Agent Execution -Related: codeframe/ui/routers/tasks.py, codeframe/agents/lead_agent.py -""" - -import asyncio -import os -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from fastapi import BackgroundTasks, Request - -from codeframe.core.models import Task, TaskStatus - - -@pytest.fixture -def mock_request(): - """Create mock starlette Request for rate limiter.""" - request = MagicMock(spec=Request) - request.client = MagicMock() - request.client.host = "127.0.0.1" - request.headers = {} - request.state = MagicMock() - request.state.user = None - return request - - -# ============================================================================ -# Unit Tests for start_development_execution Background Task -# ============================================================================ - - -class TestStartDevelopmentExecution: - """Tests for the start_development_execution background task function.""" - - @pytest.fixture - def mock_db(self): - """Create mock Database.""" - db = MagicMock() - db.get_project.return_value = { - "id": 1, - "name": "Test Project", - "phase": "active" - } - return db - - @pytest.fixture - def mock_ws_manager(self): - """Create mock WebSocket manager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - @pytest.fixture - def mock_lead_agent(self): - """Create mock LeadAgent with start_multi_agent_execution.""" - agent = MagicMock() - agent.start_multi_agent_execution = AsyncMock(return_value={ - "total_tasks": 5, - "completed": 5, - "failed": 0, - "retries": 0, - "execution_time": 10.5 - }) - return agent - - @pytest.mark.asyncio - async def test_start_development_execution_calls_lead_agent( - self, mock_db, mock_ws_manager, mock_lead_agent - ): - """Test that start_development_execution creates LeadAgent and starts execution.""" - from codeframe.ui.routers.tasks import start_development_execution - - with patch("codeframe.ui.routers.tasks.LeadAgent", return_value=mock_lead_agent): - await start_development_execution( - project_id=1, - db=mock_db, - ws_manager=mock_ws_manager, - api_key="test-api-key" - ) - - # Verify LeadAgent.start_multi_agent_execution was called - mock_lead_agent.start_multi_agent_execution.assert_called_once() - - @pytest.mark.asyncio - async def test_start_development_execution_passes_correct_parameters( - self, mock_db, mock_ws_manager, mock_lead_agent - ): - """Test that start_development_execution passes correct parameters to LeadAgent.""" - from codeframe.ui.routers.tasks import start_development_execution - - with patch("codeframe.ui.routers.tasks.LeadAgent") as MockLeadAgent: - MockLeadAgent.return_value = mock_lead_agent - - await start_development_execution( - project_id=42, - db=mock_db, - ws_manager=mock_ws_manager, - api_key="sk-ant-test" - ) - - # Verify LeadAgent was instantiated with correct args - MockLeadAgent.assert_called_once_with( - project_id=42, - db=mock_db, - api_key="sk-ant-test", - ws_manager=mock_ws_manager - ) - - @pytest.mark.asyncio - async def test_start_development_execution_handles_timeout_error( - self, mock_db, mock_ws_manager - ): - """Test that timeout errors are caught and broadcast to WebSocket.""" - from codeframe.ui.routers.tasks import start_development_execution - - mock_agent = MagicMock() - mock_agent.start_multi_agent_execution = AsyncMock( - side_effect=asyncio.TimeoutError("Execution timed out") - ) - - with patch("codeframe.ui.routers.tasks.LeadAgent", return_value=mock_agent): - # Should not raise - error is caught internally - await start_development_execution( - project_id=1, - db=mock_db, - ws_manager=mock_ws_manager, - api_key="test-key" - ) - - # Verify error was broadcast - broadcast_calls = mock_ws_manager.broadcast.call_args_list - error_broadcasts = [ - c for c in broadcast_calls - if c[0][0].get("type") == "development_failed" - ] - assert len(error_broadcasts) == 1 - assert "timed out" in error_broadcasts[0][0][0]["error"].lower() - - @pytest.mark.asyncio - async def test_start_development_execution_handles_general_exception( - self, mock_db, mock_ws_manager - ): - """Test that general exceptions are caught and broadcast.""" - from codeframe.ui.routers.tasks import start_development_execution - - mock_agent = MagicMock() - mock_agent.start_multi_agent_execution = AsyncMock( - side_effect=Exception("Database connection failed") - ) - - with patch("codeframe.ui.routers.tasks.LeadAgent", return_value=mock_agent): - await start_development_execution( - project_id=1, - db=mock_db, - ws_manager=mock_ws_manager, - api_key="test-key" - ) - - # Verify error was broadcast - broadcast_calls = mock_ws_manager.broadcast.call_args_list - error_broadcasts = [ - c for c in broadcast_calls - if c[0][0].get("type") == "development_failed" - ] - assert len(error_broadcasts) == 1 - assert "Database connection failed" in error_broadcasts[0][0][0]["error"] - - @pytest.mark.asyncio - async def test_start_development_execution_logs_success_summary( - self, mock_db, mock_ws_manager, mock_lead_agent, caplog - ): - """Test that successful execution logs summary.""" - from codeframe.ui.routers.tasks import start_development_execution - import logging - - with caplog.at_level(logging.INFO): - with patch("codeframe.ui.routers.tasks.LeadAgent", return_value=mock_lead_agent): - await start_development_execution( - project_id=1, - db=mock_db, - ws_manager=mock_ws_manager, - api_key="test-key" - ) - - # Verify success log message - assert any("completed" in record.message.lower() for record in caplog.records) - - -# ============================================================================ -# Integration Tests for Task Approval → Execution Flow -# ============================================================================ - - -class TestTaskApprovalTriggersExecution: - """Tests for approve_tasks endpoint triggering multi-agent execution.""" - - @pytest.fixture - def mock_db(self): - """Create mock Database with project and tasks.""" - db = MagicMock() - db.get_project.return_value = { - "id": 1, - "name": "Test Project", - "phase": "planning" - } - db.user_has_project_access.return_value = True - db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), - ] - db.update_task.return_value = None - return db - - @pytest.fixture - def mock_user(self): - """Create mock authenticated user.""" - user = MagicMock() - user.id = 1 - user.email = "test@example.com" - return user - - @pytest.fixture - def mock_ws_manager(self): - """Create mock WebSocket manager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - @pytest.fixture - def mock_background_tasks(self): - """Create mock BackgroundTasks.""" - bg = MagicMock(spec=BackgroundTasks) - bg.add_task = MagicMock() - return bg - - @pytest.mark.asyncio - async def test_approve_tasks_schedules_background_execution( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request - ): - """Test that approving tasks schedules multi-agent execution as background task.""" - from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Verify background task was scheduled - mock_background_tasks.add_task.assert_called_once() - - # Verify correct function and arguments - call_args = mock_background_tasks.add_task.call_args - assert call_args[0][0].__name__ == "start_development_execution" - assert call_args[0][1] == 1 # project_id - assert call_args[0][2] == mock_db - assert call_args[0][3] == mock_ws_manager - assert call_args[0][4] == "test-key" # api_key - - @pytest.mark.asyncio - async def test_approve_tasks_skips_execution_without_api_key( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request, caplog - ): - """Test that execution is skipped when ANTHROPIC_API_KEY is not set.""" - from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - import logging - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - # Remove API key from environment - env_without_key = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"} - - with caplog.at_level(logging.WARNING): - with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"), \ - patch.dict(os.environ, env_without_key, clear=True): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Approval should still succeed - assert response.success is True - - # But background task should NOT be scheduled - mock_background_tasks.add_task.assert_not_called() - - # Should log warning - assert any("ANTHROPIC_API_KEY" in record.message for record in caplog.records) - - @pytest.mark.asyncio - async def test_approve_tasks_rejection_does_not_trigger_execution( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request - ): - """Test that rejecting tasks does not trigger execution.""" - from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - - body = TaskApprovalRequest(approved=False, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Rejection response - assert response.success is False - - # Background task should NOT be scheduled for rejection - mock_background_tasks.add_task.assert_not_called() - - @pytest.mark.asyncio - async def test_approve_tasks_returns_immediately( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request - ): - """Test that approve_tasks returns immediately (doesn't wait for execution).""" - from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - import time - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - start_time = time.time() - - with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}): - response = await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - elapsed = time.time() - start_time - - # Should return quickly (< 1 second) - execution happens in background - assert elapsed < 1.0 - assert response.success is True - - -# ============================================================================ -# WebSocket Event Tests -# ============================================================================ - - -class TestDevelopmentFailedBroadcast: - """Tests for development_failed WebSocket event broadcast.""" - - @pytest.fixture - def mock_ws_manager(self): - """Create mock WebSocket manager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - @pytest.mark.asyncio - async def test_development_failed_message_format(self, mock_ws_manager): - """Test that development_failed message has correct format.""" - from codeframe.ui.routers.tasks import start_development_execution - - mock_agent = MagicMock() - mock_agent.start_multi_agent_execution = AsyncMock( - side_effect=Exception("Test error") - ) - - with patch("codeframe.ui.routers.tasks.LeadAgent", return_value=mock_agent): - await start_development_execution( - project_id=42, - db=MagicMock(), - ws_manager=mock_ws_manager, - api_key="test-key" - ) - - # Find development_failed broadcast - broadcast_calls = mock_ws_manager.broadcast.call_args_list - error_broadcasts = [ - c for c in broadcast_calls - if c[0][0].get("type") == "development_failed" - ] - - assert len(error_broadcasts) == 1 - message = error_broadcasts[0][0][0] - - # Verify message format - assert message["type"] == "development_failed" - assert message["project_id"] == 42 - assert "error" in message - assert "timestamp" in message - assert message["timestamp"].endswith("Z") # ISO format with Z suffix - - @pytest.mark.asyncio - async def test_development_failed_includes_error_details(self, mock_ws_manager): - """Test that error message is included in broadcast.""" - from codeframe.ui.routers.tasks import start_development_execution - - mock_agent = MagicMock() - mock_agent.start_multi_agent_execution = AsyncMock( - side_effect=ValueError("Invalid task dependency graph") - ) - - with patch("codeframe.ui.routers.tasks.LeadAgent", return_value=mock_agent): - await start_development_execution( - project_id=1, - db=MagicMock(), - ws_manager=mock_ws_manager, - api_key="test-key" - ) - - # Find error broadcast - error_broadcasts = [ - c for c in mock_ws_manager.broadcast.call_args_list - if c[0][0].get("type") == "development_failed" - ] - - assert "Invalid task dependency graph" in error_broadcasts[0][0][0]["error"] - - -# ============================================================================ -# Edge Case Tests -# ============================================================================ - - -class TestExecutionEdgeCases: - """Tests for edge cases in multi-agent execution trigger.""" - - @pytest.fixture - def mock_db(self): - """Create mock Database.""" - db = MagicMock() - db.get_project.return_value = {"id": 1, "name": "Test", "phase": "planning"} - db.user_has_project_access.return_value = True - db.get_project_tasks.return_value = [ - Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), - ] - db.update_task.return_value = None - return db - - @pytest.fixture - def mock_user(self): - """Create mock user.""" - user = MagicMock() - user.id = 1 - return user - - @pytest.fixture - def mock_ws_manager(self): - """Create mock WebSocket manager.""" - manager = MagicMock() - manager.broadcast = AsyncMock() - return manager - - @pytest.fixture - def mock_background_tasks(self): - """Create mock BackgroundTasks.""" - bg = MagicMock(spec=BackgroundTasks) - bg.add_task = MagicMock() - return bg - - @pytest.mark.asyncio - async def test_lead_agent_instantiation_error_is_handled(self, mock_ws_manager): - """Test that LeadAgent instantiation errors are handled gracefully.""" - from codeframe.ui.routers.tasks import start_development_execution - - with patch("codeframe.ui.routers.tasks.LeadAgent") as MockLeadAgent: - MockLeadAgent.side_effect = RuntimeError("Failed to initialize agent pool") - - # Should not raise - await start_development_execution( - project_id=1, - db=MagicMock(), - ws_manager=mock_ws_manager, - api_key="test-key" - ) - - # Error should be broadcast - error_broadcasts = [ - c for c in mock_ws_manager.broadcast.call_args_list - if c[0][0].get("type") == "development_failed" - ] - assert len(error_broadcasts) == 1 - - @pytest.mark.asyncio - async def test_empty_api_key_treated_as_missing( - self, mock_db, mock_user, mock_ws_manager, mock_background_tasks, mock_request - ): - """Test that empty string API key is treated same as missing.""" - from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest - - body = TaskApprovalRequest(approved=True, excluded_task_ids=[]) - - with patch("codeframe.ui.routers.tasks.manager", mock_ws_manager), \ - patch("codeframe.ui.routers.tasks.PhaseManager"), \ - patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}): - await approve_tasks( - request=mock_request, - project_id=1, - body=body, - background_tasks=mock_background_tasks, - db=mock_db, - current_user=mock_user - ) - - # Empty key should not trigger execution - mock_background_tasks.add_task.assert_not_called() - - -# ============================================================================ -# Signature Compatibility Tests -# ============================================================================ - - -class TestApproveTasksSignature: - """Tests to ensure approve_tasks signature is correct after modification.""" - - def test_approve_tasks_accepts_background_tasks_parameter(self): - """Test that approve_tasks function accepts BackgroundTasks parameter.""" - from codeframe.ui.routers.tasks import approve_tasks - import inspect - - sig = inspect.signature(approve_tasks) - params = list(sig.parameters.keys()) - - # Must have background_tasks parameter - assert "background_tasks" in params - - def test_approve_tasks_background_tasks_has_correct_type(self): - """Test that background_tasks parameter has correct type annotation.""" - from codeframe.ui.routers.tasks import approve_tasks - import inspect - - sig = inspect.signature(approve_tasks) - bg_param = sig.parameters.get("background_tasks") - - # Should be annotated as BackgroundTasks - assert bg_param is not None - assert bg_param.annotation == BackgroundTasks diff --git a/tests/ui/test_websocket_proactive.py b/tests/ui/test_websocket_proactive.py deleted file mode 100644 index 32d87e43..00000000 --- a/tests/ui/test_websocket_proactive.py +++ /dev/null @@ -1,527 +0,0 @@ -""" -Tests for proactive WebSocket messaging system. - -This module tests the new proactive messaging features: -1. Connection acknowledgment after subscription -2. Periodic heartbeat messages every 30 seconds -3. Initial state snapshot delivery on subscription - -These features transform the WebSocket from passive (only responding to client messages) -to proactive (actively sending connection health and state information). -""" - -import asyncio -import json -import pytest -from contextlib import asynccontextmanager -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch - -from fastapi import WebSocket, WebSocketDisconnect - -from codeframe.ui.routers.websocket import websocket_endpoint, HEARTBEAT_INTERVAL_SECONDS - - -@pytest.fixture -def mock_websocket(): - """Create a mock WebSocket connection with authentication token.""" - ws = AsyncMock(spec=WebSocket) - ws.accept = AsyncMock() - ws.send_json = AsyncMock() - ws.receive_text = AsyncMock() - ws.close = AsyncMock() - ws.query_params = MagicMock() - ws.query_params.get = MagicMock(return_value="test-jwt-token") - return ws - - -@pytest.fixture -def mock_manager(): - """Create a mock ConnectionManager.""" - manager = MagicMock() - manager.connect = AsyncMock() - manager.disconnect = AsyncMock() - manager.subscription_manager = MagicMock() - manager.subscription_manager.subscribe = AsyncMock() - manager.subscription_manager.unsubscribe = AsyncMock() - return manager - - -@pytest.fixture -def mock_db(): - """Create a mock Database for project access and state queries.""" - db = MagicMock() - db.user_has_project_access = MagicMock(return_value=True) - # Mock project retrieval for initial state snapshot - db.get_project = MagicMock(return_value={ - "id": 1, - "name": "Test Project", - "status": "active", - "phase": "development", - }) - return db - - -@pytest.fixture -def mock_jwt_auth(): - """Create mock JWT authentication that returns a valid user.""" - mock_user = MagicMock() - mock_user.id = 1 - mock_user.is_active = True - - mock_result = MagicMock() - mock_result.scalar_one_or_none = MagicMock(return_value=mock_user) - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - @asynccontextmanager - async def mock_session_context(): - yield mock_session - - def mock_session_maker(): - return mock_session_context() - - return { - "jwt_payload": {"sub": "1", "aud": "fastapi-users:auth"}, - "session_maker": mock_session_maker, - "user": mock_user, - } - - -@pytest.fixture(autouse=True) -def apply_jwt_auth_patches(mock_jwt_auth): - """Auto-applied fixture that patches JWT auth for all WebSocket tests.""" - with patch( - "codeframe.ui.routers.websocket.pyjwt.decode", - return_value=mock_jwt_auth["jwt_payload"], - ), patch( - "codeframe.ui.routers.websocket.get_async_session_maker", - return_value=mock_jwt_auth["session_maker"], - ): - yield - - -class TestConnectionAcknowledgment: - """Tests for connection acknowledgment message sent after subscription.""" - - @pytest.mark.asyncio - async def test_sends_connection_ack_after_subscription(self, mock_websocket, mock_manager, mock_db): - """Test that connection_ack is sent immediately after successful subscription.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Find connection_ack message in sent messages - ack_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "connection_ack" - ] - assert len(ack_calls) >= 1, "Should send connection_ack after subscription" - - ack_message = ack_calls[0][0][0] - assert ack_message["project_id"] == 1 - assert "timestamp" in ack_message - assert ack_message["message"] == "Connected to real-time updates" - - @pytest.mark.asyncio - async def test_connection_ack_sent_after_subscribed_message(self, mock_websocket, mock_manager, mock_db): - """Test that connection_ack is sent AFTER the subscribed confirmation.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Get all message types in order - message_types = [ - call[0][0].get("type") for call in mock_websocket.send_json.call_args_list - ] - - # Find positions - subscribed_pos = message_types.index("subscribed") if "subscribed" in message_types else -1 - ack_pos = message_types.index("connection_ack") if "connection_ack" in message_types else -1 - - assert subscribed_pos >= 0, "Should send subscribed message" - assert ack_pos >= 0, "Should send connection_ack message" - assert ack_pos > subscribed_pos, "connection_ack should come after subscribed" - - @pytest.mark.asyncio - async def test_connection_ack_has_valid_timestamp(self, mock_websocket, mock_manager, mock_db): - """Test that connection_ack timestamp is a valid ISO8601 format.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - ack_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "connection_ack" - ] - - timestamp = ack_calls[0][0][0]["timestamp"] - # Should be parseable as ISO8601 - parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) - assert parsed is not None - - @pytest.mark.asyncio - async def test_no_connection_ack_on_failed_subscription(self, mock_websocket, mock_manager, mock_db): - """Test that connection_ack is NOT sent when subscription fails.""" - mock_db.user_has_project_access = MagicMock(return_value=False) - - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT have connection_ack - ack_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "connection_ack" - ] - assert len(ack_calls) == 0, "Should not send connection_ack when access denied" - - -class TestHeartbeatMechanism: - """Tests for periodic heartbeat message functionality.""" - - def test_heartbeat_interval_constant_exists(self): - """Test that HEARTBEAT_INTERVAL_SECONDS constant is defined.""" - assert HEARTBEAT_INTERVAL_SECONDS == 30 - - @pytest.mark.asyncio - async def test_heartbeat_task_starts_after_subscription(self, mock_websocket, mock_manager, mock_db): - """Test that heartbeat task is started after successful subscription.""" - # Use a short timeout to verify heartbeat task gets created - received_messages = [] - call_count = 0 - - async def receive_with_timeout(): - nonlocal call_count - call_count += 1 - if call_count == 1: - return json.dumps({"type": "subscribe", "project_id": 1}) - # Wait a bit to allow heartbeat task to start, then disconnect - await asyncio.sleep(0.1) - raise WebSocketDisconnect() - - mock_websocket.receive_text = receive_with_timeout - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - with patch("codeframe.ui.routers.websocket.HEARTBEAT_INTERVAL_SECONDS", 0.05): - # Start the endpoint in a task so we can cancel it - task = asyncio.create_task(websocket_endpoint(mock_websocket, db=mock_db)) - try: - await asyncio.wait_for(task, timeout=0.3) - except (asyncio.TimeoutError, asyncio.CancelledError): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - # Check if heartbeat was sent - heartbeat_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "heartbeat" - ] - # With 0.05 second interval and ~0.2 seconds of waiting, should have at least 1 heartbeat - assert len(heartbeat_calls) >= 1, "Should send at least one heartbeat message" - - @pytest.mark.asyncio - async def test_heartbeat_contains_required_fields(self, mock_websocket, mock_manager, mock_db): - """Test that heartbeat messages contain required fields.""" - call_count = 0 - - async def receive_with_timeout(): - nonlocal call_count - call_count += 1 - if call_count == 1: - return json.dumps({"type": "subscribe", "project_id": 1}) - await asyncio.sleep(0.1) - raise WebSocketDisconnect() - - mock_websocket.receive_text = receive_with_timeout - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - with patch("codeframe.ui.routers.websocket.HEARTBEAT_INTERVAL_SECONDS", 0.05): - task = asyncio.create_task(websocket_endpoint(mock_websocket, db=mock_db)) - try: - await asyncio.wait_for(task, timeout=0.3) - except (asyncio.TimeoutError, asyncio.CancelledError): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - heartbeat_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "heartbeat" - ] - - assert len(heartbeat_calls) > 0, "Should receive at least one heartbeat" - heartbeat = heartbeat_calls[0][0][0] - assert heartbeat["type"] == "heartbeat" - assert heartbeat["project_id"] == 1 - assert "timestamp" in heartbeat - - @pytest.mark.asyncio - async def test_heartbeat_task_cancelled_on_disconnect(self, mock_websocket, mock_manager, mock_db): - """Test that heartbeat task is properly cancelled when client disconnects.""" - call_count = 0 - - async def receive_with_timeout(): - nonlocal call_count - call_count += 1 - if call_count == 1: - return json.dumps({"type": "subscribe", "project_id": 1}) - await asyncio.sleep(0.05) - raise WebSocketDisconnect() - - mock_websocket.receive_text = receive_with_timeout - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - with patch("codeframe.ui.routers.websocket.HEARTBEAT_INTERVAL_SECONDS", 0.1): - # This should complete without hanging - await asyncio.wait_for( - websocket_endpoint(mock_websocket, db=mock_db), - timeout=1.0 - ) - - # If we get here without timeout, the heartbeat task was properly cancelled - mock_manager.disconnect.assert_called_once() - - @pytest.mark.asyncio - async def test_heartbeat_task_cancelled_on_unsubscribe(self, mock_websocket, mock_manager, mock_db): - """Test that heartbeat task is cancelled when client unsubscribes.""" - call_count = 0 - - async def receive_messages(): - nonlocal call_count - call_count += 1 - if call_count == 1: - return json.dumps({"type": "subscribe", "project_id": 1}) - elif call_count == 2: - # Wait a bit to let heartbeat task start - await asyncio.sleep(0.02) - return json.dumps({"type": "unsubscribe", "project_id": 1}) - # Wait to verify no more heartbeats after unsubscribe - await asyncio.sleep(0.1) - raise WebSocketDisconnect() - - mock_websocket.receive_text = receive_messages - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - with patch("codeframe.ui.routers.websocket.HEARTBEAT_INTERVAL_SECONDS", 0.01): - await asyncio.wait_for( - websocket_endpoint(mock_websocket, db=mock_db), - timeout=1.0 - ) - - # Verify unsubscribed message was sent - unsubscribed_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "unsubscribed" - ] - assert len(unsubscribed_calls) == 1, "Should send unsubscribed message" - - -class TestInitialStateSnapshot: - """Tests for initial state snapshot sent on subscription.""" - - @pytest.mark.asyncio - async def test_sends_project_status_after_subscription(self, mock_websocket, mock_manager, mock_db): - """Test that project_status is sent after successful subscription.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Find project_status message - status_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "project_status" - ] - assert len(status_calls) >= 1, "Should send project_status after subscription" - - status_message = status_calls[0][0][0] - assert status_message["project_id"] == 1 - assert "status" in status_message - assert "phase" in status_message - - @pytest.mark.asyncio - async def test_project_status_contains_project_data(self, mock_websocket, mock_manager, mock_db): - """Test that project_status contains data from database.""" - mock_db.get_project = MagicMock(return_value={ - "id": 1, - "name": "My Project", - "status": "planning", - "phase": "discovery", - }) - - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - status_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "project_status" - ] - - status_message = status_calls[0][0][0] - assert status_message["status"] == "planning" - assert status_message["phase"] == "discovery" - - @pytest.mark.asyncio - async def test_project_status_handles_missing_project(self, mock_websocket, mock_manager, mock_db): - """Test graceful handling when project doesn't exist.""" - mock_db.get_project = MagicMock(return_value=None) - - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - # Should not raise exception - await websocket_endpoint(mock_websocket, db=mock_db) - - # Subscription should still succeed - subscribed_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "subscribed" - ] - assert len(subscribed_calls) >= 1 - - @pytest.mark.asyncio - async def test_project_status_handles_db_exception(self, mock_websocket, mock_manager, mock_db): - """Test graceful handling when database query fails.""" - mock_db.get_project = MagicMock(side_effect=Exception("DB error")) - - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - # Should not raise exception - await websocket_endpoint(mock_websocket, db=mock_db) - - # Subscription should still succeed even if project query fails - subscribed_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "subscribed" - ] - assert len(subscribed_calls) >= 1 - - -class TestMessageSequence: - """Tests for correct ordering of proactive messages.""" - - @pytest.mark.asyncio - async def test_message_sequence_on_subscription(self, mock_websocket, mock_manager, mock_db): - """Test the correct sequence: subscribed → connection_ack → project_status.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Get message types in order - message_types = [ - call[0][0].get("type") for call in mock_websocket.send_json.call_args_list - ] - - # Find positions - subscribed_pos = message_types.index("subscribed") if "subscribed" in message_types else -1 - ack_pos = message_types.index("connection_ack") if "connection_ack" in message_types else -1 - status_pos = message_types.index("project_status") if "project_status" in message_types else -1 - - # Verify sequence - assert subscribed_pos >= 0, "Must have subscribed message" - assert ack_pos > subscribed_pos, "connection_ack should follow subscribed" - assert status_pos > subscribed_pos, "project_status should follow subscribed" - - @pytest.mark.asyncio - async def test_multiple_subscriptions_each_get_messages(self, mock_websocket, mock_manager, mock_db): - """Test that each subscription gets its own set of proactive messages.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - json.dumps({"type": "subscribe", "project_id": 2}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Count connection_ack messages - ack_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "connection_ack" - ] - assert len(ack_calls) == 2, "Each subscription should get connection_ack" - - # Verify different project_ids - project_ids = [call[0][0]["project_id"] for call in ack_calls] - assert 1 in project_ids - assert 2 in project_ids - - -class TestHeartbeatBroadcastHelper: - """Tests for the broadcast_heartbeat helper function.""" - - @pytest.mark.asyncio - async def test_broadcast_heartbeat_function_exists(self): - """Test that broadcast_heartbeat function is exported.""" - from codeframe.ui.websocket_broadcasts import broadcast_heartbeat - assert callable(broadcast_heartbeat) - - @pytest.mark.asyncio - async def test_broadcast_heartbeat_message_format(self): - """Test that broadcast_heartbeat sends correct message format.""" - from codeframe.ui.websocket_broadcasts import broadcast_heartbeat - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock() - - await broadcast_heartbeat(mock_manager, project_id=1) - - mock_manager.broadcast.assert_called_once() - message = mock_manager.broadcast.call_args[0][0] - - assert message["type"] == "heartbeat" - assert message["project_id"] == 1 - assert "timestamp" in message - - @pytest.mark.asyncio - async def test_broadcast_heartbeat_handles_exception(self): - """Test that broadcast_heartbeat handles broadcast exceptions gracefully.""" - from codeframe.ui.websocket_broadcasts import broadcast_heartbeat - - mock_manager = MagicMock() - mock_manager.broadcast = AsyncMock(side_effect=Exception("Broadcast failed")) - - # Should not raise - await broadcast_heartbeat(mock_manager, project_id=1) diff --git a/tests/ui/test_websocket_router.py b/tests/ui/test_websocket_router.py deleted file mode 100644 index cf6227a5..00000000 --- a/tests/ui/test_websocket_router.py +++ /dev/null @@ -1,652 +0,0 @@ -""" -Tests for WebSocket router message handlers (cf-45.2). - -Tests ensure that the WebSocket router correctly handles: -- Subscribe messages with validation and subscription tracking -- Unsubscribe messages with validation and cleanup -- Error handling for invalid messages -- Edge cases like missing or invalid project_id -""" - -import pytest -import json -from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import WebSocket, WebSocketDisconnect -from fastapi.testclient import TestClient - -from codeframe.ui.routers.websocket import router, websocket_endpoint - - -@pytest.fixture -def mock_websocket(): - """Create a mock WebSocket connection with authentication token.""" - ws = AsyncMock(spec=WebSocket) - ws.accept = AsyncMock() - ws.send_json = AsyncMock() - ws.receive_text = AsyncMock() - ws.close = AsyncMock() - # Mock query_params for JWT authentication token - ws.query_params = MagicMock() - ws.query_params.get = MagicMock(return_value="test-jwt-token") - return ws - - -@pytest.fixture -def mock_manager(): - """Create a mock ConnectionManager.""" - manager = MagicMock() - manager.connect = AsyncMock() - manager.disconnect = AsyncMock() - manager.subscription_manager = MagicMock() - manager.subscription_manager.subscribe = AsyncMock() - manager.subscription_manager.unsubscribe = AsyncMock() - return manager - - -@pytest.fixture -def mock_db(): - """Create a mock Database for project access checks.""" - db = MagicMock() - # Mock user_has_project_access to always return True (user has access to all projects) - db.user_has_project_access = MagicMock(return_value=True) - return db - - -@pytest.fixture -def mock_jwt_auth(): - """Create mock JWT authentication that returns a valid user.""" - # Mock user object - mock_user = MagicMock() - mock_user.id = 1 - mock_user.is_active = True - - # Mock SQLAlchemy session result - mock_result = MagicMock() - mock_result.scalar_one_or_none = MagicMock(return_value=mock_user) - - # Mock async session - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - # Create async context manager for session - @asynccontextmanager - async def mock_session_context(): - yield mock_session - - # Mock session maker that returns fresh context manager each time - def mock_session_maker(): - return mock_session_context() - - return { - "jwt_payload": {"sub": "1", "aud": "fastapi-users:auth"}, - "session_maker": mock_session_maker, - "user": mock_user, - } - - -@pytest.fixture(autouse=True) -def apply_jwt_auth_patches(mock_jwt_auth): - """Auto-applied fixture that patches JWT auth for all WebSocket tests.""" - with patch( - "codeframe.ui.routers.websocket.pyjwt.decode", - return_value=mock_jwt_auth["jwt_payload"], - ), patch( - "codeframe.ui.routers.websocket.get_async_session_maker", - return_value=mock_jwt_auth["session_maker"], - ): - yield - - -class TestSubscribeHandler: - """Tests for subscribe message handler.""" - - @pytest.mark.asyncio - async def test_subscribe_valid_project_id(self, mock_websocket, mock_manager, mock_db): - """Test subscribe with valid project_id.""" - # Setup: Subscribe then disconnect - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify subscription was tracked - mock_manager.subscription_manager.subscribe.assert_called_once_with(mock_websocket, 1) - - # Verify confirmation was sent - assert mock_websocket.send_json.call_count >= 1 - confirm_call = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "subscribed" - ] - assert len(confirm_call) > 0 - assert confirm_call[0][0][0]["project_id"] == 1 - - @pytest.mark.asyncio - async def test_subscribe_missing_project_id(self, mock_websocket, mock_manager, mock_db): - """Test subscribe with missing project_id.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call subscribe - mock_manager.subscription_manager.subscribe.assert_not_called() - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "project_id" in error_calls[0][0][0].get("error", "").lower() - - @pytest.mark.asyncio - async def test_subscribe_invalid_project_id_type_string(self, mock_websocket, mock_manager, mock_db): - """Test subscribe with string project_id (invalid type).""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": "not_an_int"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call subscribe - mock_manager.subscription_manager.subscribe.assert_not_called() - - # Should send error about type - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "integer" in error_calls[0][0][0].get("error", "").lower() - - @pytest.mark.asyncio - async def test_subscribe_invalid_project_id_type_float(self, mock_websocket, mock_manager, mock_db): - """Test subscribe with float project_id (invalid type).""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1.5}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call subscribe (float is not int) - mock_manager.subscription_manager.subscribe.assert_not_called() - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - - @pytest.mark.asyncio - async def test_subscribe_negative_project_id(self, mock_websocket, mock_manager, mock_db): - """Test subscribe with negative project_id.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": -1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call subscribe - mock_manager.subscription_manager.subscribe.assert_not_called() - - # Should send error about positive integer - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "positive" in error_calls[0][0][0].get("error", "").lower() - - @pytest.mark.asyncio - async def test_subscribe_zero_project_id(self, mock_websocket, mock_manager, mock_db): - """Test subscribe with zero project_id.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 0}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call subscribe - mock_manager.subscription_manager.subscribe.assert_not_called() - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - - @pytest.mark.asyncio - async def test_subscribe_exception_handling(self, mock_websocket, mock_manager, mock_db): - """Test subscribe handles exceptions gracefully.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - # Make subscribe raise an exception - mock_manager.subscription_manager.subscribe.side_effect = Exception("DB error") - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should send error response - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "subscribe" in error_calls[0][0][0].get("error", "").lower() - - @pytest.mark.asyncio - async def test_subscribe_multiple_projects(self, mock_websocket, mock_manager, mock_db): - """Test subscribing to multiple projects.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - json.dumps({"type": "subscribe", "project_id": 2}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should call subscribe twice - assert mock_manager.subscription_manager.subscribe.call_count == 2 - - # Verify calls - calls = mock_manager.subscription_manager.subscribe.call_args_list - assert calls[0][0][1] == 1 - assert calls[1][0][1] == 2 - - -class TestUnsubscribeHandler: - """Tests for unsubscribe message handler.""" - - @pytest.mark.asyncio - async def test_unsubscribe_valid_project_id(self, mock_websocket, mock_manager, mock_db): - """Test unsubscribe with valid project_id.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "unsubscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify unsubscription was tracked - mock_manager.subscription_manager.unsubscribe.assert_called_once_with(mock_websocket, 1) - - # Verify confirmation was sent - confirm_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "unsubscribed" - ] - assert len(confirm_calls) > 0 - assert confirm_calls[0][0][0]["project_id"] == 1 - - @pytest.mark.asyncio - async def test_unsubscribe_missing_project_id(self, mock_websocket, mock_manager, mock_db): - """Test unsubscribe with missing project_id.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "unsubscribe"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call unsubscribe - mock_manager.subscription_manager.unsubscribe.assert_not_called() - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "project_id" in error_calls[0][0][0].get("error", "").lower() - - @pytest.mark.asyncio - async def test_unsubscribe_invalid_project_id_type(self, mock_websocket, mock_manager, mock_db): - """Test unsubscribe with string project_id (invalid type).""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "unsubscribe", "project_id": "not_an_int"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call unsubscribe - mock_manager.subscription_manager.unsubscribe.assert_not_called() - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - - @pytest.mark.asyncio - async def test_unsubscribe_negative_project_id(self, mock_websocket, mock_manager, mock_db): - """Test unsubscribe with negative project_id.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "unsubscribe", "project_id": -1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should NOT call unsubscribe - mock_manager.subscription_manager.unsubscribe.assert_not_called() - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - - @pytest.mark.asyncio - async def test_unsubscribe_exception_handling(self, mock_websocket, mock_manager, mock_db): - """Test unsubscribe handles exceptions gracefully.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "unsubscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - # Make unsubscribe raise an exception - mock_manager.subscription_manager.unsubscribe.side_effect = Exception("DB error") - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should send error response - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "unsubscribe" in error_calls[0][0][0].get("error", "").lower() - - @pytest.mark.asyncio - async def test_unsubscribe_not_subscribed(self, mock_websocket, mock_manager, mock_db): - """Test unsubscribe from project not subscribed to.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "unsubscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - # unsubscribe should still succeed (idempotent) - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should call unsubscribe - mock_manager.subscription_manager.unsubscribe.assert_called_once_with(mock_websocket, 1) - - # Should send confirmation (even if wasn't subscribed) - confirm_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "unsubscribed" - ] - assert len(confirm_calls) > 0 - - -class TestSubscribeUnsubscribeSequence: - """Tests for complex subscription sequences.""" - - @pytest.mark.asyncio - async def test_subscribe_unsubscribe_sequence(self, mock_websocket, mock_manager, mock_db): - """Test subscribe then unsubscribe sequence.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - json.dumps({"type": "unsubscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify both calls were made - mock_manager.subscription_manager.subscribe.assert_called_once_with(mock_websocket, 1) - mock_manager.subscription_manager.unsubscribe.assert_called_once_with(mock_websocket, 1) - - @pytest.mark.asyncio - async def test_ping_subscribe_ping_sequence(self, mock_websocket, mock_manager, mock_db): - """Test ping, subscribe, then ping again.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "ping"}), - json.dumps({"type": "subscribe", "project_id": 1}), - json.dumps({"type": "ping"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify subscribe was called - mock_manager.subscription_manager.subscribe.assert_called_once_with(mock_websocket, 1) - - # Verify pongs were sent - pong_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "pong" - ] - assert len(pong_calls) == 2 - - @pytest.mark.asyncio - async def test_mixed_valid_and_invalid_messages(self, mock_websocket, mock_manager, mock_db): - """Test handling mix of valid and invalid messages.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - json.dumps({"type": "subscribe"}), # Invalid - missing project_id - json.dumps({"type": "subscribe", "project_id": 2}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should call subscribe twice (for valid messages) - assert mock_manager.subscription_manager.subscribe.call_count == 2 - - # Verify the calls - calls = mock_manager.subscription_manager.subscribe.call_args_list - assert calls[0][0][1] == 1 - assert calls[1][0][1] == 2 - - # Should have sent one error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) == 1 - - -class TestDisconnectCleanup: - """Tests for disconnect cleanup behavior.""" - - @pytest.mark.asyncio - async def test_disconnect_calls_cleanup(self, mock_websocket, mock_manager, mock_db): - """Test that disconnect calls subscription cleanup.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify disconnect was called - mock_manager.disconnect.assert_called_once_with(mock_websocket) - - @pytest.mark.asyncio - async def test_disconnect_on_exception(self, mock_websocket, mock_manager, mock_db): - """Test that disconnect is called even on exception.""" - mock_websocket.receive_text.side_effect = Exception("Connection error") - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify disconnect was called despite exception - mock_manager.disconnect.assert_called_once_with(mock_websocket) - - @pytest.mark.asyncio - async def test_websocket_close_on_disconnect(self, mock_websocket, mock_manager, mock_db): - """Test that WebSocket is closed on disconnect.""" - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "ping"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Verify close was called - mock_websocket.close.assert_called_once() - - -class TestMalformedJsonHandling: - """Tests for malformed JSON handling.""" - - @pytest.mark.asyncio - async def test_malformed_json_error_response(self, mock_websocket, mock_manager, mock_db): - """Test malformed JSON sends error response.""" - mock_websocket.receive_text.side_effect = [ - '{"type": "subscribe" invalid json}', - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should send error - error_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "error" - ] - assert len(error_calls) > 0 - assert "JSON" in error_calls[0][0][0].get("error", "") - - @pytest.mark.asyncio - async def test_continues_after_malformed_json(self, mock_websocket, mock_manager, mock_db): - """Test connection continues after malformed JSON.""" - mock_websocket.receive_text.side_effect = [ - '{"type": "subscribe" invalid json}', - json.dumps({"type": "ping"}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Should send pong response (shows connection continued) - pong_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "pong" - ] - assert len(pong_calls) > 0 - - -class TestDocstringCompliance: - """Tests to verify WebSocket endpoint follows documented behavior.""" - - @pytest.mark.asyncio - async def test_documented_message_types_supported(self, mock_websocket, mock_manager, mock_db): - """Test that all documented message types are handled.""" - # From docstring: ping, subscribe - mock_websocket.receive_text.side_effect = [ - json.dumps({"type": "ping"}), - json.dumps({"type": "subscribe", "project_id": 1}), - WebSocketDisconnect(), - ] - - with patch("codeframe.ui.routers.websocket.manager", mock_manager): - await websocket_endpoint(mock_websocket, db=mock_db) - - # Both should be handled without error - assert mock_manager.subscription_manager.subscribe.called - pong_calls = [ - call for call in mock_websocket.send_json.call_args_list - if call[0][0].get("type") == "pong" - ] - assert len(pong_calls) > 0 - - -class TestWebSocketHealthEndpoint: - """Tests for /ws/health HTTP endpoint.""" - - def test_websocket_health_endpoint_returns_ready_status(self): - """Test /ws/health endpoint returns ready status.""" - # Create test client with the router - from fastapi import FastAPI - test_app = FastAPI() - test_app.include_router(router) - - with TestClient(test_app) as client: - response = client.get("/ws/health") - - assert response.status_code == 200 - assert response.json() == {"status": "ready"} - - def test_websocket_health_endpoint_is_http_get(self): - """Test /ws/health endpoint only accepts GET requests.""" - # Create test client with the router - from fastapi import FastAPI - test_app = FastAPI() - test_app.include_router(router) - - with TestClient(test_app) as client: - # GET should work - response = client.get("/ws/health") - assert response.status_code == 200 - - # POST should fail - response = client.post("/ws/health") - assert response.status_code == 405 # Method Not Allowed - - def test_websocket_health_endpoint_content_type(self): - """Test /ws/health endpoint returns JSON content type.""" - # Create test client with the router - from fastapi import FastAPI - test_app = FastAPI() - test_app.include_router(router) - - with TestClient(test_app) as client: - response = client.get("/ws/health") - - assert response.status_code == 200 - assert "application/json" in response.headers["content-type"] - - def test_websocket_health_endpoint_is_fast(self): - """Test /ws/health endpoint responds quickly (<100ms).""" - import time - from fastapi import FastAPI - test_app = FastAPI() - test_app.include_router(router) - - with TestClient(test_app) as client: - start_time = time.time() - response = client.get("/ws/health") - elapsed_time = time.time() - start_time - - assert response.status_code == 200 - assert elapsed_time < 0.1 # Should respond in less than 100ms diff --git a/tests/unit/test_pr_router.py b/tests/unit/test_pr_router.py deleted file mode 100644 index adfd5ed6..00000000 --- a/tests/unit/test_pr_router.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Unit tests for PR router (TDD - written before implementation).""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime, UTC - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from codeframe.ui.routers.prs import router -from codeframe.git.github_integration import PRDetails, MergeResult, GitHubAPIError - - -@pytest.fixture -def app(): - """Create test FastAPI app with PR router.""" - app = FastAPI() - app.include_router(router) - return app - - -@pytest.fixture -def mock_db(): - """Create mock database with PR repository.""" - db = MagicMock() - db.get_project.return_value = {"id": 1, "name": "Test Project"} - db.user_has_project_access.return_value = True - - # Mock PR repository - db.pull_requests = MagicMock() - db.pull_requests.create_pr.return_value = 1 - db.pull_requests.get_pr.return_value = { - "id": 1, - "project_id": 1, - "pr_number": 42, - "pr_url": "https://github.com/owner/repo/pull/42", - "title": "Test PR", - "body": "Test body", - "status": "open", - "branch_name": "feature/test", - "base_branch": "main", - "head_branch": "feature/test", - "created_at": datetime.now(UTC).isoformat(), - } - db.pull_requests.list_prs.return_value = [ - { - "id": 1, - "pr_number": 42, - "title": "PR 1", - "status": "open", - }, - { - "id": 2, - "pr_number": 43, - "title": "PR 2", - "status": "open", - }, - ] - db.pull_requests.get_pr_by_number.return_value = { - "id": 1, - "pr_number": 42, - "title": "Test PR", - "status": "open", - } - - return db - - -@pytest.fixture -def mock_user(): - """Create mock authenticated user.""" - user = MagicMock() - user.id = 1 - user.email = "test@example.com" - return user - - -@pytest.fixture -def mock_github_config(): - """Mock GlobalConfig with GitHub credentials.""" - config = MagicMock() - config.github_token = "ghp_test_token" - config.github_repo = "owner/test-repo" - return config - - -@pytest.fixture -def client(app, mock_db, mock_user, mock_github_config): - """Create test client with dependencies overridden.""" - from codeframe.ui.dependencies import get_db - from codeframe.auth import get_current_user - - # Override dependencies - app.dependency_overrides[get_db] = lambda: mock_db - app.dependency_overrides[get_current_user] = lambda: mock_user - - # Mock the config - with patch("codeframe.ui.routers.prs.get_global_config", return_value=mock_github_config): - yield TestClient(app) - - -class TestCreatePR: - """Tests for POST /api/projects/{project_id}/prs.""" - - def test_create_pr_success(self, client, mock_db): - """Test successful PR creation.""" - mock_pr_details = PRDetails( - number=42, - url="https://github.com/owner/repo/pull/42", - state="open", - title="Test PR", - body="Test body", - created_at=datetime.now(UTC), - merged_at=None, - head_branch="feature/test", - base_branch="main", - ) - - with patch("codeframe.ui.routers.prs.GitHubIntegration") as MockGH: - mock_gh_instance = AsyncMock() - mock_gh_instance.create_pull_request.return_value = mock_pr_details - MockGH.return_value = mock_gh_instance - - response = client.post( - "/api/projects/1/prs", - json={ - "branch": "feature/test", - "title": "Test PR", - "body": "Test body", - "base": "main", - }, - ) - - assert response.status_code == 201 - data = response.json() - assert data["pr_number"] == 42 - assert data["pr_url"] == "https://github.com/owner/repo/pull/42" - assert data["status"] == "open" - - def test_create_pr_project_not_found(self, client, mock_db): - """Test PR creation with non-existent project.""" - mock_db.get_project.return_value = None - - response = client.post( - "/api/projects/999/prs", - json={ - "branch": "feature/test", - "title": "Test PR", - "body": "Test body", - }, - ) - - assert response.status_code == 404 - - def test_create_pr_access_denied(self, client, mock_db): - """Test PR creation without project access.""" - mock_db.user_has_project_access.return_value = False - - response = client.post( - "/api/projects/1/prs", - json={ - "branch": "feature/test", - "title": "Test PR", - "body": "Test body", - }, - ) - - assert response.status_code == 403 - - def test_create_pr_github_not_configured(self, app, mock_db, mock_user): - """Test PR creation when GitHub is not configured.""" - from codeframe.ui.dependencies import get_db - from codeframe.auth import get_current_user - - app.dependency_overrides[get_db] = lambda: mock_db - app.dependency_overrides[get_current_user] = lambda: mock_user - - # Mock missing GitHub config - mock_config = MagicMock() - mock_config.github_token = None - mock_config.github_repo = None - - with patch("codeframe.ui.routers.prs.get_global_config", return_value=mock_config): - client = TestClient(app) - response = client.post( - "/api/projects/1/prs", - json={ - "branch": "feature/test", - "title": "Test PR", - "body": "Test body", - }, - ) - - assert response.status_code == 400 - assert "GitHub" in response.json()["detail"] - - def test_create_pr_github_api_error(self, client, mock_db): - """Test PR creation with GitHub API error.""" - with patch("codeframe.ui.routers.prs.GitHubIntegration") as MockGH: - mock_gh_instance = AsyncMock() - mock_gh_instance.create_pull_request.side_effect = GitHubAPIError( - status_code=422, - message="Validation Failed", - ) - MockGH.return_value = mock_gh_instance - - response = client.post( - "/api/projects/1/prs", - json={ - "branch": "feature/test", - "title": "Test PR", - "body": "Test body", - }, - ) - - assert response.status_code == 422 - - -class TestListPRs: - """Tests for GET /api/projects/{project_id}/prs.""" - - def test_list_prs_success(self, client, mock_db): - """Test listing PRs successfully.""" - response = client.get("/api/projects/1/prs") - - assert response.status_code == 200 - data = response.json() - assert "prs" in data - assert len(data["prs"]) == 2 - assert data["total"] == 2 - - def test_list_prs_with_status_filter(self, client, mock_db): - """Test listing PRs with status filter.""" - mock_db.pull_requests.list_prs.return_value = [ - {"id": 1, "pr_number": 42, "title": "Open PR", "status": "open"} - ] - - response = client.get("/api/projects/1/prs?status=open") - - assert response.status_code == 200 - mock_db.pull_requests.list_prs.assert_called_with(1, status="open") - - def test_list_prs_project_not_found(self, client, mock_db): - """Test listing PRs for non-existent project.""" - mock_db.get_project.return_value = None - - response = client.get("/api/projects/999/prs") - - assert response.status_code == 404 - - -class TestGetPR: - """Tests for GET /api/projects/{project_id}/prs/{pr_number}.""" - - def test_get_pr_success(self, client, mock_db): - """Test getting PR details successfully.""" - response = client.get("/api/projects/1/prs/42") - - assert response.status_code == 200 - data = response.json() - assert data["pr_number"] == 42 - assert data["title"] == "Test PR" - - def test_get_pr_not_found(self, client, mock_db): - """Test getting non-existent PR.""" - mock_db.pull_requests.get_pr_by_number.return_value = None - - response = client.get("/api/projects/1/prs/999") - - assert response.status_code == 404 - - -class TestMergePR: - """Tests for POST /api/projects/{project_id}/prs/{pr_number}/merge.""" - - def test_merge_pr_success(self, client, mock_db): - """Test successful PR merge.""" - mock_merge_result = MergeResult( - sha="abc123def456", - merged=True, - message="Pull Request successfully merged", - ) - - with patch("codeframe.ui.routers.prs.GitHubIntegration") as MockGH: - mock_gh_instance = AsyncMock() - mock_gh_instance.merge_pull_request.return_value = mock_merge_result - MockGH.return_value = mock_gh_instance - - response = client.post( - "/api/projects/1/prs/42/merge", - json={"method": "squash"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["merged"] is True - assert data["merge_commit_sha"] == "abc123def456" - - def test_merge_pr_not_mergeable(self, client, mock_db): - """Test merging non-mergeable PR.""" - with patch("codeframe.ui.routers.prs.GitHubIntegration") as MockGH: - mock_gh_instance = AsyncMock() - mock_gh_instance.merge_pull_request.side_effect = GitHubAPIError( - status_code=405, - message="Pull Request is not mergeable", - ) - MockGH.return_value = mock_gh_instance - - response = client.post( - "/api/projects/1/prs/42/merge", - json={"method": "squash"}, - ) - - assert response.status_code == 422 - - -class TestClosePR: - """Tests for POST /api/projects/{project_id}/prs/{pr_number}/close.""" - - def test_close_pr_success(self, client, mock_db): - """Test closing PR successfully.""" - with patch("codeframe.ui.routers.prs.GitHubIntegration") as MockGH: - mock_gh_instance = AsyncMock() - mock_gh_instance.close_pull_request.return_value = True - MockGH.return_value = mock_gh_instance - - response = client.post("/api/projects/1/prs/42/close") - - assert response.status_code == 200 - data = response.json() - assert data["closed"] is True - - def test_close_pr_not_found(self, client, mock_db): - """Test closing non-existent PR.""" - mock_db.pull_requests.get_pr_by_number.return_value = None - - response = client.post("/api/projects/1/prs/999/close") - - assert response.status_code == 404