diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e4374a5..36f9776 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -2,12 +2,17 @@ from fastapi import APIRouter from app.api.routes import ( admin, + agent_types, + agents, auth, events, + issues, oauth, oauth_provider, organizations, + projects, sessions, + sprints, users, ) @@ -25,3 +30,19 @@ api_router.include_router( ) # SSE events router - no prefix, routes define full paths api_router.include_router(events.router, tags=["Events"]) + +# Syndarix domain routers +api_router.include_router( + projects.router, prefix="/projects", tags=["Projects"] +) +api_router.include_router( + agent_types.router, prefix="/agent-types", tags=["Agent Types"] +) +# Issues router - routes include /projects/{project_id}/issues paths +api_router.include_router(issues.router, tags=["Issues"]) +# Agents router - routes include /projects/{project_id}/agents paths +api_router.include_router(agents.router, tags=["Agents"]) +# Sprints router - routes need prefix as they use /projects/{project_id}/sprints paths +api_router.include_router( + sprints.router, prefix="/projects/{project_id}/sprints", tags=["Sprints"] +) diff --git a/backend/app/api/routes/agent_types.py b/backend/app/api/routes/agent_types.py new file mode 100644 index 0000000..5280993 --- /dev/null +++ b/backend/app/api/routes/agent_types.py @@ -0,0 +1,462 @@ +# app/api/routes/agent_types.py +""" +AgentType configuration API endpoints. + +Provides CRUD operations for managing AI agent type templates. +Agent types define the base configuration (model, personality, expertise) +from which agent instances are spawned for projects. + +Authorization: +- Read endpoints: Any authenticated user +- Write endpoints (create, update, delete): Superusers only +""" + +import logging +import os +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import get_current_user +from app.api.dependencies.permissions import require_superuser +from app.core.database import get_db +from app.core.exceptions import ( + DuplicateError, + ErrorCode, + NotFoundError, +) +from app.crud.syndarix.agent_type import agent_type as agent_type_crud +from app.models.user import User +from app.schemas.common import ( + MessageResponse, + PaginatedResponse, + PaginationParams, + create_pagination_meta, +) +from app.schemas.syndarix import ( + AgentTypeCreate, + AgentTypeResponse, + AgentTypeUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Initialize limiter for this router +limiter = Limiter(key_func=get_remote_address) + +# Use higher rate limits in test environment +IS_TEST = os.getenv("IS_TEST", "False") == "True" +RATE_MULTIPLIER = 100 if IS_TEST else 1 + + +def _build_agent_type_response( + agent_type: Any, + instance_count: int = 0, +) -> AgentTypeResponse: + """ + Build an AgentTypeResponse from a database model. + + Args: + agent_type: AgentType model instance + instance_count: Number of agent instances for this type + + Returns: + AgentTypeResponse schema + """ + return AgentTypeResponse( + id=agent_type.id, + name=agent_type.name, + slug=agent_type.slug, + description=agent_type.description, + expertise=agent_type.expertise, + personality_prompt=agent_type.personality_prompt, + primary_model=agent_type.primary_model, + fallback_models=agent_type.fallback_models, + model_params=agent_type.model_params, + mcp_servers=agent_type.mcp_servers, + tool_permissions=agent_type.tool_permissions, + is_active=agent_type.is_active, + created_at=agent_type.created_at, + updated_at=agent_type.updated_at, + instance_count=instance_count, + ) + + +# ===== Write Endpoints (Admin Only) ===== + + +@router.post( + "", + response_model=AgentTypeResponse, + status_code=status.HTTP_201_CREATED, + summary="Create Agent Type", + description="Create a new agent type configuration (admin only)", + operation_id="create_agent_type", +) +@limiter.limit(f"{20 * RATE_MULTIPLIER}/minute") +async def create_agent_type( + request: Request, + agent_type_in: AgentTypeCreate, + admin: User = Depends(require_superuser), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Create a new agent type configuration. + + Agent types define templates for AI agents including: + - Model configuration (primary model, fallback models, parameters) + - Personality and expertise areas + - MCP server integrations and tool permissions + + Requires superuser privileges. + + Args: + request: FastAPI request object + agent_type_in: Agent type creation data + admin: Authenticated superuser + db: Database session + + Returns: + The created agent type configuration + + Raises: + DuplicateError: If slug already exists + """ + try: + agent_type = await agent_type_crud.create(db, obj_in=agent_type_in) + logger.info( + f"Admin {admin.email} created agent type: {agent_type.name} " + f"(slug: {agent_type.slug})" + ) + return _build_agent_type_response(agent_type, instance_count=0) + + except ValueError as e: + logger.warning(f"Failed to create agent type: {e!s}") + raise DuplicateError( + message=str(e), + error_code=ErrorCode.ALREADY_EXISTS, + field="slug", + ) + except Exception as e: + logger.error(f"Error creating agent type: {e!s}", exc_info=True) + raise + + +@router.patch( + "/{agent_type_id}", + response_model=AgentTypeResponse, + summary="Update Agent Type", + description="Update an existing agent type configuration (admin only)", + operation_id="update_agent_type", +) +@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute") +async def update_agent_type( + request: Request, + agent_type_id: UUID, + agent_type_in: AgentTypeUpdate, + admin: User = Depends(require_superuser), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update an existing agent type configuration. + + Partial updates are supported - only provided fields will be updated. + + Requires superuser privileges. + + Args: + request: FastAPI request object + agent_type_id: UUID of the agent type to update + agent_type_in: Agent type update data + admin: Authenticated superuser + db: Database session + + Returns: + The updated agent type configuration + + Raises: + NotFoundError: If agent type not found + DuplicateError: If new slug already exists + """ + try: + # Verify agent type exists + result = await agent_type_crud.get_with_instance_count( + db, agent_type_id=agent_type_id + ) + if not result: + raise NotFoundError( + message=f"Agent type {agent_type_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + existing_type = result["agent_type"] + instance_count = result["instance_count"] + + # Perform update + updated_type = await agent_type_crud.update( + db, db_obj=existing_type, obj_in=agent_type_in + ) + + logger.info( + f"Admin {admin.email} updated agent type: {updated_type.name} " + f"(id: {agent_type_id})" + ) + + return _build_agent_type_response(updated_type, instance_count=instance_count) + + except NotFoundError: + raise + except ValueError as e: + logger.warning(f"Failed to update agent type {agent_type_id}: {e!s}") + raise DuplicateError( + message=str(e), + error_code=ErrorCode.ALREADY_EXISTS, + field="slug", + ) + except Exception as e: + logger.error(f"Error updating agent type {agent_type_id}: {e!s}", exc_info=True) + raise + + +@router.delete( + "/{agent_type_id}", + response_model=MessageResponse, + summary="Deactivate Agent Type", + description="Deactivate an agent type (soft delete, admin only)", + operation_id="deactivate_agent_type", +) +@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute") +async def deactivate_agent_type( + request: Request, + agent_type_id: UUID, + admin: User = Depends(require_superuser), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Deactivate an agent type (soft delete). + + This sets is_active=False rather than deleting the record, + preserving referential integrity with existing agent instances. + + Requires superuser privileges. + + Args: + request: FastAPI request object + agent_type_id: UUID of the agent type to deactivate + admin: Authenticated superuser + db: Database session + + Returns: + Success message + + Raises: + NotFoundError: If agent type not found + """ + try: + deactivated = await agent_type_crud.deactivate(db, agent_type_id=agent_type_id) + + if not deactivated: + raise NotFoundError( + message=f"Agent type {agent_type_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info( + f"Admin {admin.email} deactivated agent type: {deactivated.name} " + f"(id: {agent_type_id})" + ) + + return MessageResponse( + success=True, + message=f"Agent type '{deactivated.name}' has been deactivated", + ) + + except NotFoundError: + raise + except Exception as e: + logger.error( + f"Error deactivating agent type {agent_type_id}: {e!s}", exc_info=True + ) + raise + + +# ===== Read Endpoints (Authenticated Users) ===== + + +@router.get( + "", + response_model=PaginatedResponse[AgentTypeResponse], + summary="List Agent Types", + description="Get paginated list of active agent types", + operation_id="list_agent_types", +) +@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute") +async def list_agent_types( + request: Request, + pagination: PaginationParams = Depends(), + is_active: bool = Query(True, description="Filter by active status"), + search: str | None = Query(None, description="Search by name, slug, description"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + List all agent types with pagination and filtering. + + By default, returns only active agent types. Set is_active=false + to include deactivated types (useful for admin views). + + Args: + request: FastAPI request object + pagination: Pagination parameters (page, limit) + is_active: Filter by active status (default: True) + search: Optional search term for name, slug, description + current_user: Authenticated user + db: Database session + + Returns: + Paginated list of agent types with instance counts + """ + try: + # Get agent types with instance counts + results, total = await agent_type_crud.get_multi_with_instance_counts( + db, + skip=pagination.offset, + limit=pagination.limit, + is_active=is_active, + search=search, + ) + + # Build response objects + agent_types_response = [ + _build_agent_type_response( + item["agent_type"], + instance_count=item["instance_count"], + ) + for item in results + ] + + pagination_meta = create_pagination_meta( + total=total, + page=pagination.page, + limit=pagination.limit, + items_count=len(agent_types_response), + ) + + return PaginatedResponse(data=agent_types_response, pagination=pagination_meta) + + except Exception as e: + logger.error(f"Error listing agent types: {e!s}", exc_info=True) + raise + + +@router.get( + "/{agent_type_id}", + response_model=AgentTypeResponse, + summary="Get Agent Type", + description="Get agent type details by ID", + operation_id="get_agent_type", +) +@limiter.limit(f"{100 * RATE_MULTIPLIER}/minute") +async def get_agent_type( + request: Request, + agent_type_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about a specific agent type. + + Args: + request: FastAPI request object + agent_type_id: UUID of the agent type + current_user: Authenticated user + db: Database session + + Returns: + Agent type details with instance count + + Raises: + NotFoundError: If agent type not found + """ + try: + result = await agent_type_crud.get_with_instance_count( + db, agent_type_id=agent_type_id + ) + + if not result: + raise NotFoundError( + message=f"Agent type {agent_type_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + return _build_agent_type_response( + result["agent_type"], + instance_count=result["instance_count"], + ) + + except NotFoundError: + raise + except Exception as e: + logger.error(f"Error getting agent type {agent_type_id}: {e!s}", exc_info=True) + raise + + +@router.get( + "/slug/{slug}", + response_model=AgentTypeResponse, + summary="Get Agent Type by Slug", + description="Get agent type details by slug", + operation_id="get_agent_type_by_slug", +) +@limiter.limit(f"{100 * RATE_MULTIPLIER}/minute") +async def get_agent_type_by_slug( + request: Request, + slug: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about an agent type by its slug. + + Slugs are human-readable identifiers like "product-owner" or "backend-engineer". + Useful for referencing agent types in configuration files or APIs. + + Args: + request: FastAPI request object + slug: Slug identifier of the agent type + current_user: Authenticated user + db: Database session + + Returns: + Agent type details with instance count + + Raises: + NotFoundError: If agent type not found + """ + try: + agent_type = await agent_type_crud.get_by_slug(db, slug=slug) + + if not agent_type: + raise NotFoundError( + message=f"Agent type with slug '{slug}' not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Get instance count separately + result = await agent_type_crud.get_with_instance_count( + db, agent_type_id=agent_type.id + ) + instance_count = result["instance_count"] if result else 0 + + return _build_agent_type_response(agent_type, instance_count=instance_count) + + except NotFoundError: + raise + except Exception as e: + logger.error(f"Error getting agent type by slug '{slug}': {e!s}", exc_info=True) + raise diff --git a/backend/app/api/routes/agents.py b/backend/app/api/routes/agents.py new file mode 100644 index 0000000..7793658 --- /dev/null +++ b/backend/app/api/routes/agents.py @@ -0,0 +1,952 @@ +# app/api/routes/agents.py +""" +Agent Instance management endpoints for Syndarix projects. + +These endpoints allow project owners and superusers to manage AI agent instances +within their projects, including spawning, pausing, resuming, and terminating agents. +""" + +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import get_current_user +from app.core.database import get_db +from app.core.exceptions import ( + AuthorizationError, + NotFoundError, + ValidationException, +) +from app.crud.syndarix.agent_instance import agent_instance as agent_instance_crud +from app.crud.syndarix.project import project as project_crud +from app.models.syndarix import AgentInstance, Project +from app.models.syndarix.enums import AgentStatus +from app.models.user import User +from app.schemas.common import ( + MessageResponse, + PaginatedResponse, + PaginationParams, + create_pagination_meta, +) +from app.schemas.errors import ErrorCode +from app.schemas.syndarix.agent_instance import ( + AgentInstanceCreate, + AgentInstanceMetrics, + AgentInstanceResponse, + AgentInstanceUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Initialize limiter for this router +limiter = Limiter(key_func=get_remote_address) + + +# Valid status transitions for agent lifecycle management +VALID_STATUS_TRANSITIONS: dict[AgentStatus, set[AgentStatus]] = { + AgentStatus.IDLE: {AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED}, + AgentStatus.WORKING: {AgentStatus.IDLE, AgentStatus.WAITING, AgentStatus.PAUSED, AgentStatus.TERMINATED}, + AgentStatus.WAITING: {AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED}, + AgentStatus.PAUSED: {AgentStatus.IDLE, AgentStatus.TERMINATED}, + AgentStatus.TERMINATED: set(), # Terminal state, no transitions allowed +} + + +async def verify_project_access( + db: AsyncSession, + project_id: UUID, + user: User, +) -> Project: + """ + Verify user has access to a project. + + Args: + db: Database session + project_id: UUID of the project to verify + user: Current authenticated user + + Returns: + Project: The project if access is granted + + Raises: + NotFoundError: If the project does not exist + AuthorizationError: If the user does not have access to the project + """ + project = await project_crud.get(db, id=project_id) + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + if not user.is_superuser and project.owner_id != user.id: + raise AuthorizationError( + message="You do not have access to this project", + error_code=ErrorCode.INSUFFICIENT_PERMISSIONS, + ) + return project + + +def validate_status_transition( + current_status: AgentStatus, + target_status: AgentStatus, +) -> None: + """ + Validate that a status transition is allowed. + + Args: + current_status: The agent's current status + target_status: The desired target status + + Raises: + ValidationException: If the transition is not allowed + """ + valid_targets = VALID_STATUS_TRANSITIONS.get(current_status, set()) + if target_status not in valid_targets: + raise ValidationException( + message=f"Cannot transition from {current_status.value} to {target_status.value}", + error_code=ErrorCode.VALIDATION_ERROR, + field="status", + ) + + +def build_agent_response( + agent: AgentInstance, + agent_type_name: str | None = None, + agent_type_slug: str | None = None, + project_name: str | None = None, + project_slug: str | None = None, + assigned_issues_count: int = 0, +) -> AgentInstanceResponse: + """ + Build an AgentInstanceResponse from an AgentInstance model. + + Args: + agent: The agent instance model + agent_type_name: Name of the agent type + agent_type_slug: Slug of the agent type + project_name: Name of the project + project_slug: Slug of the project + assigned_issues_count: Number of issues assigned to this agent + + Returns: + AgentInstanceResponse: The response schema + """ + return AgentInstanceResponse( + id=agent.id, + agent_type_id=agent.agent_type_id, + project_id=agent.project_id, + name=agent.name, + status=agent.status, + current_task=agent.current_task, + short_term_memory=agent.short_term_memory or {}, + long_term_memory_ref=agent.long_term_memory_ref, + session_id=agent.session_id, + last_activity_at=agent.last_activity_at, + terminated_at=agent.terminated_at, + tasks_completed=agent.tasks_completed, + tokens_used=agent.tokens_used, + cost_incurred=agent.cost_incurred, + created_at=agent.created_at, + updated_at=agent.updated_at, + agent_type_name=agent_type_name, + agent_type_slug=agent_type_slug, + project_name=project_name, + project_slug=project_slug, + assigned_issues_count=assigned_issues_count, + ) + + +# ===== Agent Instance Management Endpoints ===== + + +@router.post( + "/projects/{project_id}/agents", + response_model=AgentInstanceResponse, + status_code=status.HTTP_201_CREATED, + summary="Spawn Agent Instance", + description="Spawn a new agent instance in a project. Requires project ownership or superuser.", + operation_id="spawn_agent", +) +@limiter.limit("20/minute") +async def spawn_agent( + request: Request, + project_id: UUID, + agent_in: AgentInstanceCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Spawn a new agent instance in a project. + + Creates a new agent instance from an agent type template and assigns it + to the specified project. The agent starts in IDLE status by default. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project to spawn the agent in + agent_in: Agent instance creation data + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceResponse: The newly created agent instance + + Raises: + NotFoundError: If the project is not found + AuthorizationError: If the user lacks access to the project + ValidationException: If the agent creation data is invalid + """ + try: + # Verify project access + project = await verify_project_access(db, project_id, current_user) + + # Ensure the agent is being created for the correct project + if agent_in.project_id != project_id: + raise ValidationException( + message="Agent project_id must match the URL project_id", + error_code=ErrorCode.VALIDATION_ERROR, + field="project_id", + ) + + # Create the agent instance + agent = await agent_instance_crud.create(db, obj_in=agent_in) + + logger.info( + f"User {current_user.email} spawned agent '{agent.name}' " + f"(id={agent.id}) in project {project.slug}" + ) + + # Get agent details for response + details = await agent_instance_crud.get_with_details(db, instance_id=agent.id) + if details: + return build_agent_response( + agent=details["instance"], + agent_type_name=details.get("agent_type_name"), + agent_type_slug=details.get("agent_type_slug"), + project_name=details.get("project_name"), + project_slug=details.get("project_slug"), + assigned_issues_count=details.get("assigned_issues_count", 0), + ) + + return build_agent_response(agent) + + except (NotFoundError, AuthorizationError, ValidationException): + raise + except ValueError as e: + logger.warning(f"Failed to spawn agent: {e!s}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.VALIDATION_ERROR, + ) + except Exception as e: + logger.error(f"Error spawning agent: {e!s}", exc_info=True) + raise + + +@router.get( + "/projects/{project_id}/agents", + response_model=PaginatedResponse[AgentInstanceResponse], + summary="List Project Agents", + description="List all agent instances in a project with optional filtering.", + operation_id="list_project_agents", +) +@limiter.limit("60/minute") +async def list_project_agents( + request: Request, + project_id: UUID, + pagination: PaginationParams = Depends(), + status_filter: AgentStatus | None = Query( + None, alias="status", description="Filter by agent status" + ), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + List all agent instances in a project. + + Returns a paginated list of agents with optional status filtering. + Results are ordered by creation date (newest first). + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + pagination: Pagination parameters + status_filter: Optional filter by agent status + current_user: Current authenticated user + db: Database session + + Returns: + PaginatedResponse[AgentInstanceResponse]: Paginated list of agents + + Raises: + NotFoundError: If the project is not found + AuthorizationError: If the user lacks access to the project + """ + try: + # Verify project access + project = await verify_project_access(db, project_id, current_user) + + # Get agents for the project + agents, total = await agent_instance_crud.get_by_project( + db, + project_id=project_id, + status=status_filter, + skip=pagination.offset, + limit=pagination.limit, + ) + + # Build response objects + agent_responses = [] + for agent in agents: + # Get details for each agent (could be optimized with bulk query) + details = await agent_instance_crud.get_with_details( + db, instance_id=agent.id + ) + if details: + agent_responses.append( + build_agent_response( + agent=details["instance"], + agent_type_name=details.get("agent_type_name"), + agent_type_slug=details.get("agent_type_slug"), + project_name=details.get("project_name"), + project_slug=details.get("project_slug"), + assigned_issues_count=details.get("assigned_issues_count", 0), + ) + ) + else: + agent_responses.append(build_agent_response(agent)) + + pagination_meta = create_pagination_meta( + total=total, + page=pagination.page, + limit=pagination.limit, + items_count=len(agent_responses), + ) + + logger.debug( + f"User {current_user.email} listed {len(agent_responses)} agents " + f"in project {project.slug}" + ) + + return PaginatedResponse(data=agent_responses, pagination=pagination_meta) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error listing project agents: {e!s}", exc_info=True) + raise + + +@router.get( + "/projects/{project_id}/agents/{agent_id}", + response_model=AgentInstanceResponse, + summary="Get Agent Details", + description="Get detailed information about a specific agent instance.", + operation_id="get_agent", +) +@limiter.limit("60/minute") +async def get_agent( + request: Request, + project_id: UUID, + agent_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about a specific agent instance. + + Returns full agent details including related entity information + (agent type name, project name) and assigned issues count. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + agent_id: UUID of the agent instance + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceResponse: The agent instance details + + Raises: + NotFoundError: If the project or agent is not found + AuthorizationError: If the user lacks access to the project + """ + try: + # Verify project access + await verify_project_access(db, project_id, current_user) + + # Get agent with full details + details = await agent_instance_crud.get_with_details(db, instance_id=agent_id) + + if not details: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + agent = details["instance"] + + # Verify agent belongs to the specified project + if agent.project_id != project_id: + raise NotFoundError( + message=f"Agent {agent_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.debug( + f"User {current_user.email} retrieved agent {agent.name} (id={agent_id})" + ) + + return build_agent_response( + agent=agent, + agent_type_name=details.get("agent_type_name"), + agent_type_slug=details.get("agent_type_slug"), + project_name=details.get("project_name"), + project_slug=details.get("project_slug"), + assigned_issues_count=details.get("assigned_issues_count", 0), + ) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error getting agent details: {e!s}", exc_info=True) + raise + + +@router.patch( + "/projects/{project_id}/agents/{agent_id}", + response_model=AgentInstanceResponse, + summary="Update Agent", + description="Update an agent instance's configuration and state.", + operation_id="update_agent", +) +@limiter.limit("30/minute") +async def update_agent( + request: Request, + project_id: UUID, + agent_id: UUID, + agent_in: AgentInstanceUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update an agent instance's configuration and state. + + Allows updating agent status, current task, memory, and other + configurable fields. Status transitions are validated according + to the agent lifecycle state machine. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + agent_id: UUID of the agent instance + agent_in: Agent update data + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceResponse: The updated agent instance + + Raises: + NotFoundError: If the project or agent is not found + AuthorizationError: If the user lacks access to the project + ValidationException: If the status transition is invalid + """ + try: + # Verify project access + await verify_project_access(db, project_id, current_user) + + # Get current agent + agent = await agent_instance_crud.get(db, id=agent_id) + if not agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify agent belongs to the specified project + if agent.project_id != project_id: + raise NotFoundError( + message=f"Agent {agent_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Validate status transition if status is being changed + if agent_in.status is not None and agent_in.status != agent.status: + validate_status_transition(agent.status, agent_in.status) + + # Update the agent + updated_agent = await agent_instance_crud.update( + db, db_obj=agent, obj_in=agent_in + ) + + logger.info( + f"User {current_user.email} updated agent {updated_agent.name} " + f"(id={agent_id})" + ) + + # Get updated details + details = await agent_instance_crud.get_with_details( + db, instance_id=updated_agent.id + ) + if details: + return build_agent_response( + agent=details["instance"], + agent_type_name=details.get("agent_type_name"), + agent_type_slug=details.get("agent_type_slug"), + project_name=details.get("project_name"), + project_slug=details.get("project_slug"), + assigned_issues_count=details.get("assigned_issues_count", 0), + ) + + return build_agent_response(updated_agent) + + except (NotFoundError, AuthorizationError, ValidationException): + raise + except Exception as e: + logger.error(f"Error updating agent: {e!s}", exc_info=True) + raise + + +@router.post( + "/projects/{project_id}/agents/{agent_id}/pause", + response_model=AgentInstanceResponse, + summary="Pause Agent", + description="Pause an agent instance, temporarily stopping its work.", + operation_id="pause_agent", +) +@limiter.limit("20/minute") +async def pause_agent( + request: Request, + project_id: UUID, + agent_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Pause an agent instance. + + Transitions the agent to PAUSED status, temporarily stopping + its work. The agent can be resumed later with the resume endpoint. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + agent_id: UUID of the agent instance + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceResponse: The paused agent instance + + Raises: + NotFoundError: If the project or agent is not found + AuthorizationError: If the user lacks access to the project + ValidationException: If the agent cannot be paused from its current state + """ + try: + # Verify project access + await verify_project_access(db, project_id, current_user) + + # Get current agent + agent = await agent_instance_crud.get(db, id=agent_id) + if not agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify agent belongs to the specified project + if agent.project_id != project_id: + raise NotFoundError( + message=f"Agent {agent_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Validate the transition to PAUSED + validate_status_transition(agent.status, AgentStatus.PAUSED) + + # Update status to PAUSED + paused_agent = await agent_instance_crud.update_status( + db, + instance_id=agent_id, + status=AgentStatus.PAUSED, + ) + + if not paused_agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info( + f"User {current_user.email} paused agent {paused_agent.name} " + f"(id={agent_id})" + ) + + # Get updated details + details = await agent_instance_crud.get_with_details( + db, instance_id=paused_agent.id + ) + if details: + return build_agent_response( + agent=details["instance"], + agent_type_name=details.get("agent_type_name"), + agent_type_slug=details.get("agent_type_slug"), + project_name=details.get("project_name"), + project_slug=details.get("project_slug"), + assigned_issues_count=details.get("assigned_issues_count", 0), + ) + + return build_agent_response(paused_agent) + + except (NotFoundError, AuthorizationError, ValidationException): + raise + except Exception as e: + logger.error(f"Error pausing agent: {e!s}", exc_info=True) + raise + + +@router.post( + "/projects/{project_id}/agents/{agent_id}/resume", + response_model=AgentInstanceResponse, + summary="Resume Agent", + description="Resume a paused agent instance.", + operation_id="resume_agent", +) +@limiter.limit("20/minute") +async def resume_agent( + request: Request, + project_id: UUID, + agent_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Resume a paused agent instance. + + Transitions the agent from PAUSED back to IDLE status, + allowing it to accept new work. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + agent_id: UUID of the agent instance + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceResponse: The resumed agent instance + + Raises: + NotFoundError: If the project or agent is not found + AuthorizationError: If the user lacks access to the project + ValidationException: If the agent cannot be resumed from its current state + """ + try: + # Verify project access + await verify_project_access(db, project_id, current_user) + + # Get current agent + agent = await agent_instance_crud.get(db, id=agent_id) + if not agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify agent belongs to the specified project + if agent.project_id != project_id: + raise NotFoundError( + message=f"Agent {agent_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Validate the transition to IDLE (resume) + validate_status_transition(agent.status, AgentStatus.IDLE) + + # Update status to IDLE + resumed_agent = await agent_instance_crud.update_status( + db, + instance_id=agent_id, + status=AgentStatus.IDLE, + ) + + if not resumed_agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info( + f"User {current_user.email} resumed agent {resumed_agent.name} " + f"(id={agent_id})" + ) + + # Get updated details + details = await agent_instance_crud.get_with_details( + db, instance_id=resumed_agent.id + ) + if details: + return build_agent_response( + agent=details["instance"], + agent_type_name=details.get("agent_type_name"), + agent_type_slug=details.get("agent_type_slug"), + project_name=details.get("project_name"), + project_slug=details.get("project_slug"), + assigned_issues_count=details.get("assigned_issues_count", 0), + ) + + return build_agent_response(resumed_agent) + + except (NotFoundError, AuthorizationError, ValidationException): + raise + except Exception as e: + logger.error(f"Error resuming agent: {e!s}", exc_info=True) + raise + + +@router.delete( + "/projects/{project_id}/agents/{agent_id}", + response_model=MessageResponse, + summary="Terminate Agent", + description="Terminate an agent instance, permanently stopping it.", + operation_id="terminate_agent", +) +@limiter.limit("10/minute") +async def terminate_agent( + request: Request, + project_id: UUID, + agent_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Terminate an agent instance. + + Permanently terminates the agent, setting its status to TERMINATED. + This action cannot be undone - a new agent must be spawned if needed. + The agent's session and current task are cleared. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + agent_id: UUID of the agent instance + current_user: Current authenticated user + db: Database session + + Returns: + MessageResponse: Confirmation message + + Raises: + NotFoundError: If the project or agent is not found + AuthorizationError: If the user lacks access to the project + ValidationException: If the agent is already terminated + """ + try: + # Verify project access + await verify_project_access(db, project_id, current_user) + + # Get current agent + agent = await agent_instance_crud.get(db, id=agent_id) + if not agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify agent belongs to the specified project + if agent.project_id != project_id: + raise NotFoundError( + message=f"Agent {agent_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Check if already terminated + if agent.status == AgentStatus.TERMINATED: + raise ValidationException( + message="Agent is already terminated", + error_code=ErrorCode.VALIDATION_ERROR, + field="status", + ) + + # Validate the transition to TERMINATED + validate_status_transition(agent.status, AgentStatus.TERMINATED) + + agent_name = agent.name + + # Terminate the agent + terminated_agent = await agent_instance_crud.terminate( + db, instance_id=agent_id + ) + + if not terminated_agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info( + f"User {current_user.email} terminated agent {agent_name} " + f"(id={agent_id})" + ) + + return MessageResponse( + success=True, + message=f"Agent '{agent_name}' has been terminated", + ) + + except (NotFoundError, AuthorizationError, ValidationException): + raise + except Exception as e: + logger.error(f"Error terminating agent: {e!s}", exc_info=True) + raise + + +@router.get( + "/projects/{project_id}/agents/{agent_id}/metrics", + response_model=AgentInstanceMetrics, + summary="Get Agent Metrics", + description="Get usage metrics for a specific agent instance.", + operation_id="get_agent_metrics", +) +@limiter.limit("60/minute") +async def get_agent_metrics( + request: Request, + project_id: UUID, + agent_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get usage metrics for a specific agent instance. + + Returns metrics including tasks completed, tokens used, + and cost incurred for the specified agent. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + agent_id: UUID of the agent instance + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceMetrics: Agent usage metrics + + Raises: + NotFoundError: If the project or agent is not found + AuthorizationError: If the user lacks access to the project + """ + try: + # Verify project access + await verify_project_access(db, project_id, current_user) + + # Get agent + agent = await agent_instance_crud.get(db, id=agent_id) + if not agent: + raise NotFoundError( + message=f"Agent {agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify agent belongs to the specified project + if agent.project_id != project_id: + raise NotFoundError( + message=f"Agent {agent_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Calculate metrics for this single agent + # For a single agent, we report its individual metrics + is_active = agent.status == AgentStatus.WORKING + is_idle = agent.status == AgentStatus.IDLE + + logger.debug( + f"User {current_user.email} retrieved metrics for agent {agent.name} " + f"(id={agent_id})" + ) + + return AgentInstanceMetrics( + total_instances=1, + active_instances=1 if is_active else 0, + idle_instances=1 if is_idle else 0, + total_tasks_completed=agent.tasks_completed, + total_tokens_used=agent.tokens_used, + total_cost_incurred=agent.cost_incurred, + ) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error getting agent metrics: {e!s}", exc_info=True) + raise + + +@router.get( + "/projects/{project_id}/agents/metrics", + response_model=AgentInstanceMetrics, + summary="Get Project Agent Metrics", + description="Get aggregated usage metrics for all agents in a project.", + operation_id="get_project_agent_metrics", +) +@limiter.limit("60/minute") +async def get_project_agent_metrics( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get aggregated usage metrics for all agents in a project. + + Returns aggregated metrics across all agents including total + tasks completed, tokens used, and cost incurred. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project + current_user: Current authenticated user + db: Database session + + Returns: + AgentInstanceMetrics: Aggregated project agent metrics + + Raises: + NotFoundError: If the project is not found + AuthorizationError: If the user lacks access to the project + """ + try: + # Verify project access + project = await verify_project_access(db, project_id, current_user) + + # Get aggregated metrics for the project + metrics = await agent_instance_crud.get_project_metrics( + db, project_id=project_id + ) + + logger.debug( + f"User {current_user.email} retrieved project metrics for {project.slug}" + ) + + return AgentInstanceMetrics( + total_instances=metrics["total_instances"], + active_instances=metrics["active_instances"], + idle_instances=metrics["idle_instances"], + total_tasks_completed=metrics["total_tasks_completed"], + total_tokens_used=metrics["total_tokens_used"], + total_cost_incurred=metrics["total_cost_incurred"], + ) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error getting project agent metrics: {e!s}", exc_info=True) + raise diff --git a/backend/app/api/routes/issues.py b/backend/app/api/routes/issues.py new file mode 100644 index 0000000..9d049c1 --- /dev/null +++ b/backend/app/api/routes/issues.py @@ -0,0 +1,836 @@ +# app/api/routes/issues.py +""" +Issue CRUD API endpoints for Syndarix projects. + +Provides endpoints for managing issues within projects, including: +- Create, read, update, delete operations +- Filtering by status, priority, labels, sprint, assigned agent +- Search across title and body +- Assignment to agents +- External issue tracker sync triggers +""" + +import logging +import os +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import get_current_user +from app.core.database import get_db +from app.core.exceptions import ( + AuthorizationError, + NotFoundError, + ValidationException, +) +from app.crud.syndarix.agent_instance import agent_instance as agent_instance_crud +from app.crud.syndarix.issue import issue as issue_crud +from app.crud.syndarix.project import project as project_crud +from app.models.syndarix.enums import IssuePriority, IssueStatus, SyncStatus +from app.models.user import User +from app.schemas.common import ( + MessageResponse, + PaginatedResponse, + PaginationParams, + SortOrder, + create_pagination_meta, +) +from app.schemas.errors import ErrorCode +from app.schemas.syndarix.issue import ( + IssueAssign, + IssueCreate, + IssueResponse, + IssueStats, + IssueUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Initialize limiter for this router +limiter = Limiter(key_func=get_remote_address) + +# Use higher rate limits in test environment +IS_TEST = os.getenv("IS_TEST", "False") == "True" +RATE_MULTIPLIER = 100 if IS_TEST else 1 + + +async def verify_project_ownership( + db: AsyncSession, + project_id: UUID, + user: User, +) -> None: + """ + Verify that the user owns the project or is a superuser. + + Args: + db: Database session + project_id: Project UUID to verify + user: Current authenticated user + + Raises: + NotFoundError: If project does not exist + AuthorizationError: If user does not own the project + """ + project = await project_crud.get(db, id=project_id) + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + if not user.is_superuser and project.owner_id != user.id: + raise AuthorizationError( + message="You do not have access to this project", + error_code=ErrorCode.INSUFFICIENT_PERMISSIONS, + ) + + +def _build_issue_response( + issue: Any, + project_name: str | None = None, + project_slug: str | None = None, + sprint_name: str | None = None, + assigned_agent_type_name: str | None = None, +) -> IssueResponse: + """ + Build an IssueResponse from an Issue model instance. + + Args: + issue: Issue model instance + project_name: Optional project name from relationship + project_slug: Optional project slug from relationship + sprint_name: Optional sprint name from relationship + assigned_agent_type_name: Optional agent type name from relationship + + Returns: + IssueResponse schema instance + """ + return IssueResponse( + id=issue.id, + project_id=issue.project_id, + title=issue.title, + body=issue.body, + status=issue.status, + priority=issue.priority, + labels=issue.labels or [], + assigned_agent_id=issue.assigned_agent_id, + human_assignee=issue.human_assignee, + sprint_id=issue.sprint_id, + story_points=issue.story_points, + external_tracker_type=issue.external_tracker_type, + external_issue_id=issue.external_issue_id, + remote_url=issue.remote_url, + external_issue_number=issue.external_issue_number, + sync_status=issue.sync_status, + last_synced_at=issue.last_synced_at, + external_updated_at=issue.external_updated_at, + closed_at=issue.closed_at, + created_at=issue.created_at, + updated_at=issue.updated_at, + project_name=project_name, + project_slug=project_slug, + sprint_name=sprint_name, + assigned_agent_type_name=assigned_agent_type_name, + ) + + +# ===== Issue CRUD Endpoints ===== + + +@router.post( + "/projects/{project_id}/issues", + response_model=IssueResponse, + status_code=status.HTTP_201_CREATED, + summary="Create Issue", + description="Create a new issue in a project", + operation_id="create_issue", +) +@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute") +async def create_issue( + request: Request, + project_id: UUID, + issue_in: IssueCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Create a new issue within a project. + + The user must own the project or be a superuser. + The project_id in the path takes precedence over any project_id in the body. + + Args: + request: FastAPI request object (for rate limiting) + project_id: UUID of the project to create the issue in + issue_in: Issue creation data + current_user: Authenticated user + db: Database session + + Returns: + Created issue with full details + + Raises: + NotFoundError: If project not found + AuthorizationError: If user lacks access + ValidationException: If assigned agent not in project + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Override project_id from path + issue_in.project_id = project_id + + # Validate assigned agent if provided + if issue_in.assigned_agent_id: + agent = await agent_instance_crud.get(db, id=issue_in.assigned_agent_id) + if not agent: + raise NotFoundError( + message=f"Agent instance {issue_in.assigned_agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + if agent.project_id != project_id: + raise ValidationException( + message="Agent instance does not belong to this project", + error_code=ErrorCode.VALIDATION_ERROR, + field="assigned_agent_id", + ) + + try: + issue = await issue_crud.create(db, obj_in=issue_in) + logger.info( + f"User {current_user.email} created issue '{issue.title}' " + f"in project {project_id}" + ) + + # Get project details for response + project = await project_crud.get(db, id=project_id) + + return _build_issue_response( + issue, + project_name=project.name if project else None, + project_slug=project.slug if project else None, + ) + + except ValueError as e: + logger.warning(f"Failed to create issue: {e!s}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.VALIDATION_ERROR, + ) + except Exception as e: + logger.error(f"Error creating issue: {e!s}", exc_info=True) + raise + + +@router.get( + "/projects/{project_id}/issues", + response_model=PaginatedResponse[IssueResponse], + summary="List Issues", + description="Get paginated list of issues in a project with filtering", + operation_id="list_issues", +) +@limiter.limit(f"{120 * RATE_MULTIPLIER}/minute") +async def list_issues( + request: Request, + project_id: UUID, + pagination: PaginationParams = Depends(), + status_filter: IssueStatus | None = Query( + None, alias="status", description="Filter by issue status" + ), + priority: IssuePriority | None = Query(None, description="Filter by priority"), + labels: list[str] | None = Query( + None, description="Filter by labels (comma-separated)" + ), + sprint_id: UUID | None = Query(None, description="Filter by sprint ID"), + assigned_agent_id: UUID | None = Query( + None, description="Filter by assigned agent ID" + ), + sync_status: SyncStatus | None = Query( + None, description="Filter by sync status" + ), + search: str | None = Query( + None, min_length=1, max_length=100, description="Search in title and body" + ), + sort_by: str = Query( + "created_at", + description="Field to sort by (created_at, updated_at, priority, status, title)", + ), + sort_order: SortOrder = Query(SortOrder.DESC, description="Sort order"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + List issues in a project with comprehensive filtering options. + + Supports filtering by: + - status: Issue status (open, in_progress, in_review, blocked, closed) + - priority: Issue priority (low, medium, high, critical) + - labels: Match issues containing any of the provided labels + - sprint_id: Issues in a specific sprint + - assigned_agent_id: Issues assigned to a specific agent + - sync_status: External tracker sync status + - search: Full-text search in title and body + + Args: + request: FastAPI request object + project_id: Project UUID + pagination: Pagination parameters + status_filter: Optional status filter + priority: Optional priority filter + labels: Optional labels filter + sprint_id: Optional sprint filter + assigned_agent_id: Optional agent assignment filter + sync_status: Optional sync status filter + search: Optional search query + sort_by: Field to sort by + sort_order: Sort direction + current_user: Authenticated user + db: Database session + + Returns: + Paginated list of issues matching filters + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + try: + # Get filtered issues + issues, total = await issue_crud.get_by_project( + db, + project_id=project_id, + status=status_filter, + priority=priority, + sprint_id=sprint_id, + assigned_agent_id=assigned_agent_id, + labels=labels, + search=search, + skip=pagination.offset, + limit=pagination.limit, + sort_by=sort_by, + sort_order=sort_order.value, + ) + + # Build response objects + issue_responses = [_build_issue_response(issue) for issue in issues] + + pagination_meta = create_pagination_meta( + total=total, + page=pagination.page, + limit=pagination.limit, + items_count=len(issue_responses), + ) + + return PaginatedResponse(data=issue_responses, pagination=pagination_meta) + + except Exception as e: + logger.error( + f"Error listing issues for project {project_id}: {e!s}", exc_info=True + ) + raise + + +@router.get( + "/projects/{project_id}/issues/{issue_id}", + response_model=IssueResponse, + summary="Get Issue", + description="Get detailed information about a specific issue", + operation_id="get_issue", +) +@limiter.limit(f"{120 * RATE_MULTIPLIER}/minute") +async def get_issue( + request: Request, + project_id: UUID, + issue_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about a specific issue. + + Returns the issue with expanded relationship data including + project name, sprint name, and assigned agent type name. + + Args: + request: FastAPI request object + project_id: Project UUID + issue_id: Issue UUID + current_user: Authenticated user + db: Database session + + Returns: + Issue details with relationship data + + Raises: + NotFoundError: If project or issue not found + AuthorizationError: If user lacks access + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get issue with details + issue_data = await issue_crud.get_with_details(db, issue_id=issue_id) + + if not issue_data: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + issue = issue_data["issue"] + + # Verify issue belongs to the project + if issue.project_id != project_id: + raise NotFoundError( + message=f"Issue {issue_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + return _build_issue_response( + issue, + project_name=issue_data.get("project_name"), + project_slug=issue_data.get("project_slug"), + sprint_name=issue_data.get("sprint_name"), + assigned_agent_type_name=issue_data.get("assigned_agent_type_name"), + ) + + +@router.patch( + "/projects/{project_id}/issues/{issue_id}", + response_model=IssueResponse, + summary="Update Issue", + description="Update an existing issue", + operation_id="update_issue", +) +@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute") +async def update_issue( + request: Request, + project_id: UUID, + issue_id: UUID, + issue_in: IssueUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update an existing issue. + + All fields are optional - only provided fields will be updated. + Validates that assigned agent belongs to the same project. + + Args: + request: FastAPI request object + project_id: Project UUID + issue_id: Issue UUID + issue_in: Fields to update + current_user: Authenticated user + db: Database session + + Returns: + Updated issue details + + Raises: + NotFoundError: If project or issue not found + AuthorizationError: If user lacks access + ValidationException: If validation fails + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get existing issue + issue = await issue_crud.get(db, id=issue_id) + if not issue: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify issue belongs to the project + if issue.project_id != project_id: + raise NotFoundError( + message=f"Issue {issue_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Validate assigned agent if being updated + if issue_in.assigned_agent_id is not None: + agent = await agent_instance_crud.get(db, id=issue_in.assigned_agent_id) + if not agent: + raise NotFoundError( + message=f"Agent instance {issue_in.assigned_agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + if agent.project_id != project_id: + raise ValidationException( + message="Agent instance does not belong to this project", + error_code=ErrorCode.VALIDATION_ERROR, + field="assigned_agent_id", + ) + + try: + updated_issue = await issue_crud.update(db, db_obj=issue, obj_in=issue_in) + logger.info( + f"User {current_user.email} updated issue {issue_id} in project {project_id}" + ) + + # Get full details for response + issue_data = await issue_crud.get_with_details(db, issue_id=issue_id) + + return _build_issue_response( + updated_issue, + project_name=issue_data.get("project_name") if issue_data else None, + project_slug=issue_data.get("project_slug") if issue_data else None, + sprint_name=issue_data.get("sprint_name") if issue_data else None, + assigned_agent_type_name=issue_data.get("assigned_agent_type_name") + if issue_data + else None, + ) + + except ValueError as e: + logger.warning(f"Failed to update issue {issue_id}: {e!s}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.VALIDATION_ERROR, + ) + except Exception as e: + logger.error(f"Error updating issue {issue_id}: {e!s}", exc_info=True) + raise + + +@router.delete( + "/projects/{project_id}/issues/{issue_id}", + response_model=MessageResponse, + summary="Delete Issue", + description="Soft delete an issue", + operation_id="delete_issue", +) +@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute") +async def delete_issue( + request: Request, + project_id: UUID, + issue_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Soft delete an issue. + + The issue will be marked as deleted but retained in the database. + This preserves historical data and allows potential recovery. + + Args: + request: FastAPI request object + project_id: Project UUID + issue_id: Issue UUID + current_user: Authenticated user + db: Database session + + Returns: + Success message + + Raises: + NotFoundError: If project or issue not found + AuthorizationError: If user lacks access + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get existing issue + issue = await issue_crud.get(db, id=issue_id) + if not issue: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify issue belongs to the project + if issue.project_id != project_id: + raise NotFoundError( + message=f"Issue {issue_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + try: + await issue_crud.soft_delete(db, id=issue_id) + logger.info( + f"User {current_user.email} deleted issue {issue_id} " + f"('{issue.title}') from project {project_id}" + ) + + return MessageResponse( + success=True, + message=f"Issue '{issue.title}' has been deleted", + ) + + except Exception as e: + logger.error(f"Error deleting issue {issue_id}: {e!s}", exc_info=True) + raise + + +# ===== Issue Assignment Endpoint ===== + + +@router.post( + "/projects/{project_id}/issues/{issue_id}/assign", + response_model=IssueResponse, + summary="Assign Issue", + description="Assign an issue to an agent or human", + operation_id="assign_issue", +) +@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute") +async def assign_issue( + request: Request, + project_id: UUID, + issue_id: UUID, + assignment: IssueAssign, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Assign an issue to an agent or human. + + Only one type of assignment is allowed at a time: + - assigned_agent_id: Assign to an AI agent instance + - human_assignee: Assign to a human (name/email string) + + To unassign, pass both as null/None. + + Args: + request: FastAPI request object + project_id: Project UUID + issue_id: Issue UUID + assignment: Assignment data + current_user: Authenticated user + db: Database session + + Returns: + Updated issue with assignment + + Raises: + NotFoundError: If project, issue, or agent not found + AuthorizationError: If user lacks access + ValidationException: If agent not in project + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get existing issue + issue = await issue_crud.get(db, id=issue_id) + if not issue: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify issue belongs to the project + if issue.project_id != project_id: + raise NotFoundError( + message=f"Issue {issue_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Process assignment based on type + if assignment.assigned_agent_id: + # Validate agent exists and belongs to project + agent = await agent_instance_crud.get(db, id=assignment.assigned_agent_id) + if not agent: + raise NotFoundError( + message=f"Agent instance {assignment.assigned_agent_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + if agent.project_id != project_id: + raise ValidationException( + message="Agent instance does not belong to this project", + error_code=ErrorCode.VALIDATION_ERROR, + field="assigned_agent_id", + ) + + updated_issue = await issue_crud.assign_to_agent( + db, issue_id=issue_id, agent_id=assignment.assigned_agent_id + ) + logger.info( + f"User {current_user.email} assigned issue {issue_id} to agent {agent.name}" + ) + + elif assignment.human_assignee: + updated_issue = await issue_crud.assign_to_human( + db, issue_id=issue_id, human_assignee=assignment.human_assignee + ) + logger.info( + f"User {current_user.email} assigned issue {issue_id} " + f"to human '{assignment.human_assignee}'" + ) + + else: + # Unassign - clear both agent and human + updated_issue = await issue_crud.assign_to_agent( + db, issue_id=issue_id, agent_id=None + ) + logger.info( + f"User {current_user.email} unassigned issue {issue_id}" + ) + + if not updated_issue: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Get full details for response + issue_data = await issue_crud.get_with_details(db, issue_id=issue_id) + + return _build_issue_response( + updated_issue, + project_name=issue_data.get("project_name") if issue_data else None, + project_slug=issue_data.get("project_slug") if issue_data else None, + sprint_name=issue_data.get("sprint_name") if issue_data else None, + assigned_agent_type_name=issue_data.get("assigned_agent_type_name") + if issue_data + else None, + ) + + +# ===== Issue Sync Endpoint ===== + + +@router.post( + "/projects/{project_id}/issues/{issue_id}/sync", + response_model=MessageResponse, + summary="Trigger Issue Sync", + description="Trigger synchronization with external issue tracker", + operation_id="sync_issue", +) +@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute") +async def sync_issue( + request: Request, + project_id: UUID, + issue_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Trigger synchronization of an issue with its external tracker. + + This endpoint queues a sync task for the issue. The actual synchronization + happens asynchronously via Celery. + + Prerequisites: + - Issue must have external_tracker_type configured + - Project must have integration settings for the tracker + + Args: + request: FastAPI request object + project_id: Project UUID + issue_id: Issue UUID + current_user: Authenticated user + db: Database session + + Returns: + Message indicating sync has been triggered + + Raises: + NotFoundError: If project or issue not found + AuthorizationError: If user lacks access + ValidationException: If issue has no external tracker + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get existing issue + issue = await issue_crud.get(db, id=issue_id) + if not issue: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify issue belongs to the project + if issue.project_id != project_id: + raise NotFoundError( + message=f"Issue {issue_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + # Check if issue has external tracker configured + if not issue.external_tracker_type: + raise ValidationException( + message="Issue does not have an external tracker configured", + error_code=ErrorCode.VALIDATION_ERROR, + field="external_tracker_type", + ) + + # Update sync status to pending + await issue_crud.update_sync_status( + db, + issue_id=issue_id, + sync_status=SyncStatus.PENDING, + ) + + # TODO: Queue Celery task for actual sync + # When Celery is set up, this will be: + # from app.tasks.sync import sync_issue_task + # sync_issue_task.delay(str(issue_id)) + + logger.info( + f"User {current_user.email} triggered sync for issue {issue_id} " + f"(tracker: {issue.external_tracker_type})" + ) + + return MessageResponse( + success=True, + message=f"Sync triggered for issue '{issue.title}'. " + f"Status will update when complete.", + ) + + +# ===== Issue Statistics Endpoint ===== + + +@router.get( + "/projects/{project_id}/issues/stats", + response_model=IssueStats, + summary="Get Issue Statistics", + description="Get aggregated issue statistics for a project", + operation_id="get_issue_stats", +) +@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute") +async def get_issue_stats( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get aggregated statistics for issues in a project. + + Returns counts by status and priority, along with story point totals. + + Args: + request: FastAPI request object + project_id: Project UUID + current_user: Authenticated user + db: Database session + + Returns: + Issue statistics including counts by status/priority and story points + + Raises: + NotFoundError: If project not found + AuthorizationError: If user lacks access + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + try: + stats = await issue_crud.get_project_stats(db, project_id=project_id) + return IssueStats(**stats) + + except Exception as e: + logger.error( + f"Error getting issue stats for project {project_id}: {e!s}", + exc_info=True, + ) + raise diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py new file mode 100644 index 0000000..7c68432 --- /dev/null +++ b/backend/app/api/routes/projects.py @@ -0,0 +1,639 @@ +# app/api/routes/projects.py +""" +Project management API endpoints for Syndarix. + +These endpoints allow users to manage their AI-powered software consulting projects. +Users can create, read, update, and manage the lifecycle of their projects. +""" + +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import get_current_user +from app.core.database import get_db +from app.core.exceptions import ( + AuthorizationError, + DuplicateError, + ErrorCode, + NotFoundError, +) +from app.crud.syndarix.project import project as project_crud +from app.models.syndarix.enums import ProjectStatus +from app.models.user import User +from app.schemas.common import ( + MessageResponse, + PaginatedResponse, + PaginationParams, + create_pagination_meta, +) +from app.schemas.syndarix.project import ( + ProjectCreate, + ProjectResponse, + ProjectUpdate, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Initialize rate limiter +limiter = Limiter(key_func=get_remote_address) + + +def _build_project_response(project_data: dict[str, Any]) -> ProjectResponse: + """ + Build a ProjectResponse from project data dictionary. + + Args: + project_data: Dictionary containing project and related counts + + Returns: + ProjectResponse with all fields populated + """ + project = project_data["project"] + return ProjectResponse( + id=project.id, + name=project.name, + slug=project.slug, + description=project.description, + autonomy_level=project.autonomy_level, + status=project.status, + settings=project.settings, + owner_id=project.owner_id, + created_at=project.created_at, + updated_at=project.updated_at, + agent_count=project_data.get("agent_count", 0), + issue_count=project_data.get("issue_count", 0), + active_sprint_name=project_data.get("active_sprint_name"), + ) + + +def _check_project_ownership(project: Any, current_user: User) -> None: + """ + Check if the current user owns the project or is a superuser. + + Args: + project: The project to check ownership of + current_user: The authenticated user + + Raises: + AuthorizationError: If user doesn't own the project and isn't a superuser + """ + if not current_user.is_superuser and project.owner_id != current_user.id: + raise AuthorizationError( + message="You do not have permission to access this project", + error_code=ErrorCode.INSUFFICIENT_PERMISSIONS, + ) + + +# ============================================================================= +# Project CRUD Endpoints +# ============================================================================= + + +@router.post( + "", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, + summary="Create Project", + description=""" + Create a new project for the current user. + + The project will be owned by the authenticated user. + A unique slug is required for URL-friendly project identification. + + **Rate Limit**: 10 requests/minute + """, + operation_id="create_project", +) +@limiter.limit("10/minute") +async def create_project( + request: Request, + project_in: ProjectCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Create a new project. + + The authenticated user becomes the owner of the project. + """ + try: + # Set the owner to the current user + project_data = ProjectCreate( + name=project_in.name, + slug=project_in.slug, + description=project_in.description, + autonomy_level=project_in.autonomy_level, + status=project_in.status, + settings=project_in.settings, + owner_id=current_user.id, + ) + + project = await project_crud.create(db, obj_in=project_data) + logger.info(f"User {current_user.email} created project {project.slug}") + + return ProjectResponse( + id=project.id, + name=project.name, + slug=project.slug, + description=project.description, + autonomy_level=project.autonomy_level, + status=project.status, + settings=project.settings, + owner_id=project.owner_id, + created_at=project.created_at, + updated_at=project.updated_at, + agent_count=0, + issue_count=0, + active_sprint_name=None, + ) + + except ValueError as e: + error_msg = str(e) + if "already exists" in error_msg.lower(): + logger.warning(f"Duplicate project slug attempted: {project_in.slug}") + raise DuplicateError( + message=error_msg, + error_code=ErrorCode.DUPLICATE_ENTRY, + field="slug", + ) + logger.error(f"Error creating project: {error_msg}", exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error creating project: {e!s}", exc_info=True) + raise + + +@router.get( + "", + response_model=PaginatedResponse[ProjectResponse], + summary="List Projects", + description=""" + List projects for the current user with filtering and pagination. + + Regular users see only their own projects. + Superusers can see all projects by setting `all_projects=true`. + + **Rate Limit**: 30 requests/minute + """, + operation_id="list_projects", +) +@limiter.limit("30/minute") +async def list_projects( + request: Request, + pagination: PaginationParams = Depends(), + status_filter: ProjectStatus | None = Query( + None, alias="status", description="Filter by project status" + ), + search: str | None = Query(None, description="Search by name, slug, or description"), + all_projects: bool = Query( + False, description="Show all projects (superuser only)" + ), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + List projects with filtering, search, and pagination. + + Regular users only see their own projects. + Superusers can view all projects if all_projects is true. + """ + try: + # Determine owner filter based on user role and request + owner_id = None if (current_user.is_superuser and all_projects) else current_user.id + + projects_data, total = await project_crud.get_multi_with_counts( + db, + skip=pagination.offset, + limit=pagination.limit, + status=status_filter, + owner_id=owner_id, + search=search, + ) + + # Build response objects + project_responses = [_build_project_response(data) for data in projects_data] + + pagination_meta = create_pagination_meta( + total=total, + page=pagination.page, + limit=pagination.limit, + items_count=len(project_responses), + ) + + return PaginatedResponse(data=project_responses, pagination=pagination_meta) + + except Exception as e: + logger.error(f"Error listing projects: {e!s}", exc_info=True) + raise + + +@router.get( + "/{project_id}", + response_model=ProjectResponse, + summary="Get Project", + description=""" + Get detailed information about a specific project. + + Users can only access their own projects unless they are superusers. + + **Rate Limit**: 60 requests/minute + """, + operation_id="get_project", +) +@limiter.limit("60/minute") +async def get_project( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about a project by ID. + + Includes agent count, issue count, and active sprint name. + """ + try: + project_data = await project_crud.get_with_counts(db, project_id=project_id) + + if not project_data: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + project = project_data["project"] + _check_project_ownership(project, current_user) + + return _build_project_response(project_data) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error getting project {project_id}: {e!s}", exc_info=True) + raise + + +@router.get( + "/slug/{slug}", + response_model=ProjectResponse, + summary="Get Project by Slug", + description=""" + Get detailed information about a project by its slug. + + Users can only access their own projects unless they are superusers. + + **Rate Limit**: 60 requests/minute + """, + operation_id="get_project_by_slug", +) +@limiter.limit("60/minute") +async def get_project_by_slug( + request: Request, + slug: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about a project by slug. + + Includes agent count, issue count, and active sprint name. + """ + try: + project = await project_crud.get_by_slug(db, slug=slug) + + if not project: + raise NotFoundError( + message=f"Project with slug '{slug}' not found", + error_code=ErrorCode.NOT_FOUND, + ) + + _check_project_ownership(project, current_user) + + # Get project with counts + project_data = await project_crud.get_with_counts(db, project_id=project.id) + + if not project_data: + raise NotFoundError( + message=f"Project with slug '{slug}' not found", + error_code=ErrorCode.NOT_FOUND, + ) + + return _build_project_response(project_data) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error getting project by slug {slug}: {e!s}", exc_info=True) + raise + + +@router.patch( + "/{project_id}", + response_model=ProjectResponse, + summary="Update Project", + description=""" + Update an existing project. + + Only the project owner or a superuser can update a project. + Only provided fields will be updated. + + **Rate Limit**: 20 requests/minute + """, + operation_id="update_project", +) +@limiter.limit("20/minute") +async def update_project( + request: Request, + project_id: UUID, + project_in: ProjectUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update a project's information. + + Only the project owner or superusers can perform updates. + """ + try: + project = await project_crud.get(db, id=project_id) + + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + _check_project_ownership(project, current_user) + + # Update the project + updated_project = await project_crud.update(db, db_obj=project, obj_in=project_in) + logger.info( + f"User {current_user.email} updated project {updated_project.slug}" + ) + + # Get updated project with counts + project_data = await project_crud.get_with_counts(db, project_id=updated_project.id) + + if not project_data: + # This shouldn't happen, but handle gracefully + raise NotFoundError( + message=f"Project {project_id} not found after update", + error_code=ErrorCode.NOT_FOUND, + ) + + return _build_project_response(project_data) + + except (NotFoundError, AuthorizationError): + raise + except ValueError as e: + error_msg = str(e) + if "already exists" in error_msg.lower(): + logger.warning(f"Duplicate project slug attempted: {project_in.slug}") + raise DuplicateError( + message=error_msg, + error_code=ErrorCode.DUPLICATE_ENTRY, + field="slug", + ) + logger.error(f"Error updating project: {error_msg}", exc_info=True) + raise + except Exception as e: + logger.error(f"Error updating project {project_id}: {e!s}", exc_info=True) + raise + + +@router.delete( + "/{project_id}", + response_model=MessageResponse, + summary="Archive Project", + description=""" + Archive a project (soft delete). + + Only the project owner or a superuser can archive a project. + Archived projects are not deleted but are no longer accessible for active work. + + **Rate Limit**: 10 requests/minute + """, + operation_id="archive_project", +) +@limiter.limit("10/minute") +async def archive_project( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Archive a project by setting its status to ARCHIVED. + + This is a soft delete operation. The project data is preserved. + """ + try: + project = await project_crud.get(db, id=project_id) + + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + _check_project_ownership(project, current_user) + + # Check if project is already archived + if project.status == ProjectStatus.ARCHIVED: + return MessageResponse( + success=True, + message=f"Project '{project.name}' is already archived", + ) + + archived_project = await project_crud.archive_project(db, project_id=project_id) + + if not archived_project: + raise NotFoundError( + message=f"Failed to archive project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info(f"User {current_user.email} archived project {project.slug}") + + return MessageResponse( + success=True, + message=f"Project '{archived_project.name}' has been archived", + ) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error archiving project {project_id}: {e!s}", exc_info=True) + raise + + +# ============================================================================= +# Project Lifecycle Endpoints +# ============================================================================= + + +@router.post( + "/{project_id}/pause", + response_model=ProjectResponse, + summary="Pause Project", + description=""" + Pause an active project. + + Only ACTIVE projects can be paused. + Only the project owner or a superuser can pause a project. + + **Rate Limit**: 10 requests/minute + """, + operation_id="pause_project", +) +@limiter.limit("10/minute") +async def pause_project( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Pause an active project. + + Sets the project status to PAUSED. Only ACTIVE projects can be paused. + """ + try: + project = await project_crud.get(db, id=project_id) + + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + _check_project_ownership(project, current_user) + + # Validate current status + if project.status == ProjectStatus.PAUSED: + raise AuthorizationError( + message="Project is already paused", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + if project.status == ProjectStatus.ARCHIVED: + raise AuthorizationError( + message="Cannot pause an archived project", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + if project.status == ProjectStatus.COMPLETED: + raise AuthorizationError( + message="Cannot pause a completed project", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + # Update status to PAUSED + updated_project = await project_crud.update( + db, db_obj=project, obj_in=ProjectUpdate(status=ProjectStatus.PAUSED) + ) + logger.info(f"User {current_user.email} paused project {project.slug}") + + # Get project with counts + project_data = await project_crud.get_with_counts(db, project_id=updated_project.id) + + if not project_data: + raise NotFoundError( + message=f"Project {project_id} not found after update", + error_code=ErrorCode.NOT_FOUND, + ) + + return _build_project_response(project_data) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error pausing project {project_id}: {e!s}", exc_info=True) + raise + + +@router.post( + "/{project_id}/resume", + response_model=ProjectResponse, + summary="Resume Project", + description=""" + Resume a paused project. + + Only PAUSED projects can be resumed. + Only the project owner or a superuser can resume a project. + + **Rate Limit**: 10 requests/minute + """, + operation_id="resume_project", +) +@limiter.limit("10/minute") +async def resume_project( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Resume a paused project. + + Sets the project status back to ACTIVE. Only PAUSED projects can be resumed. + """ + try: + project = await project_crud.get(db, id=project_id) + + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + _check_project_ownership(project, current_user) + + # Validate current status + if project.status == ProjectStatus.ACTIVE: + raise AuthorizationError( + message="Project is already active", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + if project.status == ProjectStatus.ARCHIVED: + raise AuthorizationError( + message="Cannot resume an archived project", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + if project.status == ProjectStatus.COMPLETED: + raise AuthorizationError( + message="Cannot resume a completed project", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + # Update status to ACTIVE + updated_project = await project_crud.update( + db, db_obj=project, obj_in=ProjectUpdate(status=ProjectStatus.ACTIVE) + ) + logger.info(f"User {current_user.email} resumed project {project.slug}") + + # Get project with counts + project_data = await project_crud.get_with_counts(db, project_id=updated_project.id) + + if not project_data: + raise NotFoundError( + message=f"Project {project_id} not found after update", + error_code=ErrorCode.NOT_FOUND, + ) + + return _build_project_response(project_data) + + except (NotFoundError, AuthorizationError): + raise + except Exception as e: + logger.error(f"Error resuming project {project_id}: {e!s}", exc_info=True) + raise diff --git a/backend/app/api/routes/sprints.py b/backend/app/api/routes/sprints.py new file mode 100644 index 0000000..2033b83 --- /dev/null +++ b/backend/app/api/routes/sprints.py @@ -0,0 +1,931 @@ +# app/api/routes/sprints.py +""" +Sprint management API endpoints. + +Provides CRUD operations and sprint lifecycle management for projects. +All endpoints are scoped to a specific project for proper access control. +""" + +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import get_current_user +from app.core.database import get_db +from app.core.exceptions import ( + AuthorizationError, + NotFoundError, + ValidationException, +) +from app.crud.syndarix import ( + issue as issue_crud, + project as project_crud, + sprint as sprint_crud, +) +from app.models.user import User +from app.schemas.common import ( + MessageResponse, + PaginatedResponse, + PaginationParams, + create_pagination_meta, +) +from app.schemas.errors import ErrorCode +from app.schemas.syndarix import ( + IssueResponse, + SprintComplete, + SprintCreate, + SprintResponse, + SprintStart, + SprintStatus, + SprintUpdate, + SprintVelocity, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) + + +# ============================================================================ +# Helper Functions +# ============================================================================ + + +async def verify_project_ownership( + db: AsyncSession, + project_id: UUID, + user: User, +) -> None: + """ + Verify that the user has access to the project. + + Args: + db: Database session + project_id: Project ID to verify + user: Current authenticated user + + Raises: + NotFoundError: If project doesn't exist + AuthorizationError: If user doesn't have access to the project + """ + project = await project_crud.get(db, id=project_id) + if not project: + raise NotFoundError( + message=f"Project {project_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Check if user is owner or superuser + if project.owner_id != user.id and not user.is_superuser: + logger.warning( + f"User {user.id} attempted to access project {project_id} without permission" + ) + raise AuthorizationError( + message="Not authorized to access this project", + error_code=ErrorCode.INSUFFICIENT_PERMISSIONS, + ) + + +async def get_sprint_or_404( + db: AsyncSession, + sprint_id: UUID, + project_id: UUID, +) -> Any: + """ + Get a sprint by ID and verify it belongs to the project. + + Args: + db: Database session + sprint_id: Sprint ID + project_id: Project ID for verification + + Returns: + Sprint object + + Raises: + NotFoundError: If sprint doesn't exist or doesn't belong to project + """ + sprint = await sprint_crud.get(db, id=sprint_id) + if not sprint: + raise NotFoundError( + message=f"Sprint {sprint_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + if sprint.project_id != project_id: + raise NotFoundError( + message=f"Sprint {sprint_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + return sprint + + +def build_sprint_response( + sprint: Any, + issue_count: int = 0, + open_issues: int = 0, + completed_issues: int = 0, + project_name: str | None = None, + project_slug: str | None = None, +) -> SprintResponse: + """ + Build a SprintResponse from a sprint model and additional data. + + Args: + sprint: Sprint model instance + issue_count: Total number of issues + open_issues: Number of open issues + completed_issues: Number of completed issues + project_name: Project name (optional) + project_slug: Project slug (optional) + + Returns: + SprintResponse schema + """ + return SprintResponse( + id=sprint.id, + project_id=sprint.project_id, + name=sprint.name, + number=sprint.number, + goal=sprint.goal, + start_date=sprint.start_date, + end_date=sprint.end_date, + status=sprint.status, + planned_points=sprint.planned_points, + velocity=sprint.velocity, + created_at=sprint.created_at, + updated_at=sprint.updated_at, + project_name=project_name, + project_slug=project_slug, + issue_count=issue_count, + open_issues=open_issues, + completed_issues=completed_issues, + ) + + +# ============================================================================ +# Sprint CRUD Endpoints +# ============================================================================ + + +@router.post( + "", + response_model=SprintResponse, + status_code=status.HTTP_201_CREATED, + summary="Create Sprint", + description=""" + Create a new sprint for a project. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + **Business Rules**: + - Sprint number is auto-generated if not provided + - End date must be after start date + - Sprint is created in PLANNED status by default + + **Rate Limit**: 30 requests/minute + """, + operation_id="create_sprint", +) +@limiter.limit("30/minute") +async def create_sprint( + request: Request, + project_id: UUID, + sprint_in: SprintCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Create a new sprint for the specified project. + + The sprint number will be auto-generated as the next sequential number + for the project if not explicitly provided. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Ensure project_id matches + if sprint_in.project_id != project_id: + raise ValidationException( + message="Project ID in URL must match project_id in request body", + error_code=ErrorCode.VALIDATION_ERROR, + field="project_id", + ) + + try: + # Create the sprint + sprint = await sprint_crud.create(db, obj_in=sprint_in) + + logger.info( + f"User {current_user.id} created sprint '{sprint.name}' " + f"(ID: {sprint.id}) for project {project_id}" + ) + + return build_sprint_response(sprint) + + except ValueError as e: + logger.warning(f"Failed to create sprint: {e}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.VALIDATION_ERROR, + ) + except Exception as e: + logger.error(f"Error creating sprint: {e!s}", exc_info=True) + raise + + +@router.get( + "", + response_model=PaginatedResponse[SprintResponse], + summary="List Sprints", + description=""" + List all sprints for a project with pagination. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + **Filtering**: By status + + **Rate Limit**: 60 requests/minute + """, + operation_id="list_sprints", +) +@limiter.limit("60/minute") +async def list_sprints( + request: Request, + project_id: UUID, + pagination: PaginationParams = Depends(), + status_filter: SprintStatus | None = Query( + None, alias="status", description="Filter by sprint status" + ), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + List sprints for a project with optional status filtering. + + Returns sprints sorted by number (newest first). + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + try: + # Get sprints with issue counts + sprints_data, total = await sprint_crud.get_sprints_with_issue_counts( + db, + project_id=project_id, + skip=pagination.offset, + limit=pagination.limit, + ) + + # Filter by status if provided (done in memory since counts query doesn't filter) + if status_filter is not None: + sprints_data = [ + s for s in sprints_data if s["sprint"].status == status_filter + ] + total = len(sprints_data) + + # Build response objects + sprint_responses = [ + build_sprint_response( + sprint=item["sprint"], + issue_count=item["issue_count"], + open_issues=item["open_issues"], + completed_issues=item["completed_issues"], + ) + for item in sprints_data + ] + + pagination_meta = create_pagination_meta( + total=total, + page=pagination.page, + limit=pagination.limit, + items_count=len(sprint_responses), + ) + + return PaginatedResponse(data=sprint_responses, pagination=pagination_meta) + + except Exception as e: + logger.error(f"Error listing sprints for project {project_id}: {e!s}", exc_info=True) + raise + + +@router.get( + "/active", + response_model=SprintResponse | None, + summary="Get Active Sprint", + description=""" + Get the currently active sprint for a project. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + Returns null if no active sprint exists. + + **Rate Limit**: 60 requests/minute + """, + operation_id="get_active_sprint", +) +@limiter.limit("60/minute") +async def get_active_sprint( + request: Request, + project_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get the currently active sprint for the project. + + Returns None if no sprint is currently active. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + try: + sprint = await sprint_crud.get_active_sprint(db, project_id=project_id) + + if not sprint: + return None + + # Get detailed sprint information + details = await sprint_crud.get_with_details(db, sprint_id=sprint.id) + + if details: + return build_sprint_response( + sprint=details["sprint"], + issue_count=details["issue_count"], + open_issues=details["open_issues"], + completed_issues=details["completed_issues"], + project_name=details["project_name"], + project_slug=details["project_slug"], + ) + + return build_sprint_response(sprint) + + except Exception as e: + logger.error( + f"Error getting active sprint for project {project_id}: {e!s}", + exc_info=True, + ) + raise + + +@router.get( + "/{sprint_id}", + response_model=SprintResponse, + summary="Get Sprint Details", + description=""" + Get detailed information about a specific sprint. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + Includes issue counts and project information. + + **Rate Limit**: 60 requests/minute + """, + operation_id="get_sprint", +) +@limiter.limit("60/minute") +async def get_sprint( + request: Request, + project_id: UUID, + sprint_id: UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get detailed information about a specific sprint. + + Includes issue counts and related project information. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get sprint with details + details = await sprint_crud.get_with_details(db, sprint_id=sprint_id) + + if not details: + raise NotFoundError( + message=f"Sprint {sprint_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + # Verify sprint belongs to project + if details["sprint"].project_id != project_id: + raise NotFoundError( + message=f"Sprint {sprint_id} not found in project {project_id}", + error_code=ErrorCode.NOT_FOUND, + ) + + return build_sprint_response( + sprint=details["sprint"], + issue_count=details["issue_count"], + open_issues=details["open_issues"], + completed_issues=details["completed_issues"], + project_name=details["project_name"], + project_slug=details["project_slug"], + ) + + +@router.patch( + "/{sprint_id}", + response_model=SprintResponse, + summary="Update Sprint", + description=""" + Update sprint information. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + **Business Rules**: + - Cannot modify COMPLETED sprints + - Cannot modify CANCELLED sprints + - End date must remain after start date + + **Rate Limit**: 30 requests/minute + """, + operation_id="update_sprint", +) +@limiter.limit("30/minute") +async def update_sprint( + request: Request, + project_id: UUID, + sprint_id: UUID, + sprint_update: SprintUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Update sprint information. + + Completed and cancelled sprints cannot be modified. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Get sprint and verify ownership + sprint = await get_sprint_or_404(db, sprint_id, project_id) + + # Business rule: Cannot modify completed or cancelled sprints + if sprint.status in [SprintStatus.COMPLETED, SprintStatus.CANCELLED]: + raise ValidationException( + message=f"Cannot modify sprint with status '{sprint.status.value}'", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + try: + # Validate date changes + update_data = sprint_update.model_dump(exclude_unset=True) + + # Check date consistency + new_start = update_data.get("start_date", sprint.start_date) + new_end = update_data.get("end_date", sprint.end_date) + if new_end < new_start: + raise ValidationException( + message="End date must be after or equal to start date", + error_code=ErrorCode.VALIDATION_ERROR, + ) + + # Update the sprint + updated_sprint = await sprint_crud.update(db, db_obj=sprint, obj_in=sprint_update) + + logger.info( + f"User {current_user.id} updated sprint {sprint_id} in project {project_id}" + ) + + return build_sprint_response(updated_sprint) + + except ValidationException: + raise + except ValueError as e: + logger.warning(f"Failed to update sprint {sprint_id}: {e}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.VALIDATION_ERROR, + ) + except Exception as e: + logger.error(f"Error updating sprint {sprint_id}: {e!s}", exc_info=True) + raise + + +# ============================================================================ +# Sprint Lifecycle Endpoints +# ============================================================================ + + +@router.post( + "/{sprint_id}/start", + response_model=SprintResponse, + summary="Start Sprint", + description=""" + Start a planned sprint, making it the active sprint. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + **Business Rules**: + - Only PLANNED sprints can be started + - Only one active sprint per project allowed + - Calculates planned points from assigned issues + + **Rate Limit**: 10 requests/minute + """, + operation_id="start_sprint", +) +@limiter.limit("10/minute") +async def start_sprint( + request: Request, + project_id: UUID, + sprint_id: UUID, + sprint_start: SprintStart | None = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Start a planned sprint. + + Only one sprint can be active per project at a time. + The planned_points field will be calculated from assigned issues. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Verify sprint exists and belongs to project + await get_sprint_or_404(db, sprint_id, project_id) + + try: + start_date = sprint_start.start_date if sprint_start else None + started_sprint = await sprint_crud.start_sprint( + db, sprint_id=sprint_id, start_date=start_date + ) + + if not started_sprint: + raise NotFoundError( + message=f"Sprint {sprint_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info( + f"User {current_user.id} started sprint '{started_sprint.name}' " + f"(ID: {sprint_id}) in project {project_id}" + ) + + # Get updated details + details = await sprint_crud.get_with_details(db, sprint_id=sprint_id) + if details: + return build_sprint_response( + sprint=details["sprint"], + issue_count=details["issue_count"], + open_issues=details["open_issues"], + completed_issues=details["completed_issues"], + project_name=details["project_name"], + project_slug=details["project_slug"], + ) + + return build_sprint_response(started_sprint) + + except ValueError as e: + logger.warning(f"Failed to start sprint {sprint_id}: {e}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + except Exception as e: + logger.error(f"Error starting sprint {sprint_id}: {e!s}", exc_info=True) + raise + + +@router.post( + "/{sprint_id}/complete", + response_model=SprintResponse, + summary="Complete Sprint", + description=""" + Complete an active sprint and calculate velocity. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + **Business Rules**: + - Only ACTIVE sprints can be completed + - Velocity (completed story points) is auto-calculated + - Incomplete issues remain in the sprint but can be moved + + **Rate Limit**: 10 requests/minute + """, + operation_id="complete_sprint", +) +@limiter.limit("10/minute") +async def complete_sprint( + request: Request, + project_id: UUID, + sprint_id: UUID, + sprint_complete: SprintComplete | None = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Complete an active sprint. + + Calculates the actual velocity from completed issues. + Incomplete issues remain in the sprint. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Verify sprint exists and belongs to project + await get_sprint_or_404(db, sprint_id, project_id) + + try: + completed_sprint = await sprint_crud.complete_sprint(db, sprint_id=sprint_id) + + if not completed_sprint: + raise NotFoundError( + message=f"Sprint {sprint_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + logger.info( + f"User {current_user.id} completed sprint '{completed_sprint.name}' " + f"(ID: {sprint_id}) in project {project_id} with velocity {completed_sprint.velocity}" + ) + + # Get updated details + details = await sprint_crud.get_with_details(db, sprint_id=sprint_id) + if details: + return build_sprint_response( + sprint=details["sprint"], + issue_count=details["issue_count"], + open_issues=details["open_issues"], + completed_issues=details["completed_issues"], + project_name=details["project_name"], + project_slug=details["project_slug"], + ) + + return build_sprint_response(completed_sprint) + + except ValueError as e: + logger.warning(f"Failed to complete sprint {sprint_id}: {e}") + raise ValidationException( + message=str(e), + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + except Exception as e: + logger.error(f"Error completing sprint {sprint_id}: {e!s}", exc_info=True) + raise + + +# ============================================================================ +# Sprint Issues Endpoints +# ============================================================================ + + +@router.get( + "/{sprint_id}/issues", + response_model=PaginatedResponse[IssueResponse], + summary="Get Sprint Issues", + description=""" + Get all issues assigned to a sprint. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + Issues are returned sorted by priority (highest first) then creation date. + + **Rate Limit**: 60 requests/minute + """, + operation_id="get_sprint_issues", +) +@limiter.limit("60/minute") +async def get_sprint_issues( + request: Request, + project_id: UUID, + sprint_id: UUID, + pagination: PaginationParams = Depends(), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get all issues in a sprint. + + Returns issues sorted by priority and creation date. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Verify sprint exists and belongs to project + await get_sprint_or_404(db, sprint_id, project_id) + + try: + # Get issues for the sprint + issues, total = await issue_crud.get_by_project( + db, + project_id=project_id, + sprint_id=sprint_id, + skip=pagination.offset, + limit=pagination.limit, + sort_by="priority", + sort_order="desc", + ) + + # Build response objects + issue_responses = [ + IssueResponse( + id=issue.id, + project_id=issue.project_id, + title=issue.title, + body=issue.body, + status=issue.status, + priority=issue.priority, + labels=issue.labels or [], + assigned_agent_id=issue.assigned_agent_id, + human_assignee=issue.human_assignee, + sprint_id=issue.sprint_id, + story_points=issue.story_points, + external_tracker_type=issue.external_tracker_type, + external_issue_id=issue.external_issue_id, + remote_url=issue.remote_url, + external_issue_number=issue.external_issue_number, + sync_status=issue.sync_status, + last_synced_at=issue.last_synced_at, + external_updated_at=issue.external_updated_at, + closed_at=issue.closed_at, + created_at=issue.created_at, + updated_at=issue.updated_at, + ) + for issue in issues + ] + + pagination_meta = create_pagination_meta( + total=total, + page=pagination.page, + limit=pagination.limit, + items_count=len(issue_responses), + ) + + return PaginatedResponse(data=issue_responses, pagination=pagination_meta) + + except Exception as e: + logger.error( + f"Error getting issues for sprint {sprint_id}: {e!s}", exc_info=True + ) + raise + + +@router.post( + "/{sprint_id}/issues", + response_model=MessageResponse, + summary="Add Issue to Sprint", + description=""" + Add an existing issue to a sprint. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + **Business Rules**: + - Issue must belong to the same project + - Cannot add issues to COMPLETED or CANCELLED sprints + + **Rate Limit**: 30 requests/minute + """, + operation_id="add_issue_to_sprint", +) +@limiter.limit("30/minute") +async def add_issue_to_sprint( + request: Request, + project_id: UUID, + sprint_id: UUID, + issue_id: UUID = Query(..., description="ID of the issue to add to the sprint"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Add an existing issue to the sprint. + + The issue must belong to the same project as the sprint. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + # Verify sprint exists and belongs to project + sprint = await get_sprint_or_404(db, sprint_id, project_id) + + # Business rule: Cannot add issues to completed/cancelled sprints + if sprint.status in [SprintStatus.COMPLETED, SprintStatus.CANCELLED]: + raise ValidationException( + message=f"Cannot add issues to sprint with status '{sprint.status.value}'", + error_code=ErrorCode.OPERATION_FORBIDDEN, + ) + + # Verify issue exists and belongs to the same project + issue = await issue_crud.get(db, id=issue_id) + if not issue: + raise NotFoundError( + message=f"Issue {issue_id} not found", + error_code=ErrorCode.NOT_FOUND, + ) + + if issue.project_id != project_id: + raise ValidationException( + message="Issue must belong to the same project as the sprint", + error_code=ErrorCode.VALIDATION_ERROR, + ) + + try: + # Update the issue's sprint_id + from app.schemas.syndarix import IssueUpdate + + await issue_crud.update( + db, db_obj=issue, obj_in=IssueUpdate(sprint_id=sprint_id) + ) + + logger.info( + f"User {current_user.id} added issue {issue_id} to sprint {sprint_id}" + ) + + return MessageResponse( + success=True, + message=f"Issue '{issue.title}' added to sprint '{sprint.name}'", + ) + + except Exception as e: + logger.error( + f"Error adding issue {issue_id} to sprint {sprint_id}: {e!s}", + exc_info=True, + ) + raise + + +# ============================================================================ +# Sprint Metrics Endpoints +# ============================================================================ + + +@router.get( + "/velocity", + response_model=list[SprintVelocity], + summary="Get Project Velocity", + description=""" + Get velocity metrics for completed sprints in the project. + + **Authentication**: Required (Bearer token) + **Authorization**: Project owner or superuser + + Returns velocity data for the last N completed sprints (default 5). + Useful for capacity planning and sprint estimation. + + **Rate Limit**: 60 requests/minute + """, + operation_id="get_project_velocity", +) +@limiter.limit("60/minute") +async def get_project_velocity( + request: Request, + project_id: UUID, + limit: int = Query( + default=5, + ge=1, + le=20, + description="Number of completed sprints to include", + ), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """ + Get velocity metrics for completed sprints. + + Returns planned points, actual velocity, and velocity ratio + for the last N completed sprints, ordered chronologically. + """ + # Verify project access + await verify_project_ownership(db, project_id, current_user) + + try: + velocity_data = await sprint_crud.get_velocity( + db, project_id=project_id, limit=limit + ) + + return [ + SprintVelocity( + sprint_number=item["sprint_number"], + sprint_name=item["sprint_name"], + planned_points=item["planned_points"], + velocity=item["velocity"], + velocity_ratio=item["velocity_ratio"], + ) + for item in velocity_data + ] + + except Exception as e: + logger.error( + f"Error getting velocity for project {project_id}: {e!s}", exc_info=True + ) + raise diff --git a/docs/architecture/IMPLEMENTATION_ROADMAP.md b/docs/architecture/IMPLEMENTATION_ROADMAP.md index d6dc3c6..cd84362 100644 --- a/docs/architecture/IMPLEMENTATION_ROADMAP.md +++ b/docs/architecture/IMPLEMENTATION_ROADMAP.md @@ -1,8 +1,8 @@ # Syndarix Implementation Roadmap -**Version:** 1.0 -**Date:** 2025-12-29 -**Status:** Draft +**Version:** 1.2 +**Date:** 2025-12-30 +**Status:** Active Development --- @@ -12,7 +12,7 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz --- -## Phase 0: Foundation (Weeks 1-2) +## Phase 0: Foundation ✅ COMPLETE **Goal:** Establish development infrastructure and basic platform ### 0.1 Repository Setup @@ -22,62 +22,67 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [x] Complete all spike research (SPIKE-001 through SPIKE-012) - [x] Create all ADRs (ADR-001 through ADR-014) - [x] Rebrand codebase (all URLs, names, configs updated) -- [ ] Configure CI/CD pipelines -- [ ] Set up development environment documentation +- [x] Configure CI/CD pipelines (`.gitea/workflows/ci.yaml`) +- [x] Set up development environment documentation ### 0.2 Core Infrastructure -- [ ] Configure Redis for cache + pub/sub -- [ ] Set up Celery worker infrastructure -- [ ] Configure pgvector extension -- [ ] Create MCP server directory structure -- [ ] Set up Docker Compose for local development +- [x] Configure Redis for cache + pub/sub (`app/core/redis.py`) +- [x] Set up Celery worker infrastructure (4 queues: agent, git, sync, cicd) +- [x] Configure pgvector extension (migration 0003) +- [x] Create MCP server directory structure (`mcp-servers/`) +- [x] Set up Docker Compose for local development (3 compose files) ### Deliverables - [x] Fully branded Syndarix repository - [x] Complete architecture documentation (ARCHITECTURE.md) - [x] All spike research completed (12 spikes) - [x] All ADRs documented (14 ADRs) -- [ ] Working local development environment (Docker Compose) -- [ ] CI/CD pipeline running tests +- [x] Working local development environment (Docker Compose) +- [x] CI/CD pipeline running tests --- -## Phase 1: Core Platform (Weeks 3-6) +## Phase 1: Core Platform 🔄 IN PROGRESS (~85%) **Goal:** Basic project and agent management without LLM integration -### 1.1 Data Model -- [ ] Create Project entity and CRUD -- [ ] Create AgentType entity and CRUD -- [ ] Create AgentInstance entity and CRUD -- [ ] Create Issue entity with external tracker fields -- [ ] Create Sprint entity and CRUD -- [ ] Database migrations with Alembic +### 1.1 Data Model ✅ COMPLETE +- [x] Create Project entity and CRUD (`models/syndarix/project.py`) +- [x] Create AgentType entity and CRUD (`models/syndarix/agent_type.py`) +- [x] Create AgentInstance entity and CRUD (`models/syndarix/agent_instance.py`) +- [x] Create Issue entity with external tracker fields (`models/syndarix/issue.py`) +- [x] Create Sprint entity and CRUD (`models/syndarix/sprint.py`) +- [x] Database migrations with Alembic (3 migrations) -### 1.2 API Layer -- [ ] Project management endpoints -- [ ] Agent type configuration endpoints -- [ ] Agent instance management endpoints -- [ ] Issue CRUD endpoints -- [ ] Sprint management endpoints +### 1.2 API Layer ✅ COMPLETE +- [x] Project management endpoints (`api/routes/projects.py`) - Issue #28 +- [x] Agent type configuration endpoints (`api/routes/agent_types.py`) - Issue #29 +- [x] Agent instance management endpoints (`api/routes/agents.py`) - Issue #30 +- [x] Issue CRUD endpoints (`api/routes/issues.py`) - Issue #31 +- [x] Sprint management endpoints (`api/routes/sprints.py`) - Issue #32 -### 1.3 Real-time Infrastructure -- [ ] Implement EventBus with Redis Pub/Sub -- [ ] Create SSE endpoint for project events -- [ ] Implement event types enum -- [ ] Add keepalive mechanism -- [ ] Client-side SSE handling +### 1.3 Real-time Infrastructure ✅ COMPLETE +- [x] Implement EventBus with Redis Pub/Sub (`services/event_bus.py`) +- [x] Create SSE endpoint for project events (`api/routes/events.py`) +- [x] Implement event types enum (`schemas/events.py`) +- [x] Add keepalive mechanism +- [x] Client-side SSE handling (`hooks/useProjectEvents.ts`) ### 1.4 Frontend Foundation -- [ ] Project dashboard page -- [ ] Agent configuration UI -- [ ] Issue list and detail views -- [ ] Real-time activity feed component -- [ ] Basic navigation and layout +- [x] Project dashboard page (prototype) - Awaiting approval #36 +- [x] Agent configuration UI (prototype) - Awaiting approval #37 +- [x] Issue list and detail views (prototype) - Awaiting approval #38 +- [x] Real-time activity feed component (prototype) - Awaiting approval #39 +- [x] Basic navigation and layout (implemented) +- [ ] Production implementations (#40-43) - Blocked by design approvals ### Deliverables -- CRUD operations for all core entities -- Real-time event streaming working -- Basic admin UI for configuration +- [x] CRUD operations for all core entities +- [x] Real-time event streaming working +- [ ] Basic admin UI for configuration (blocked by design approvals) + +### Blocking Items +- Issues #36-39: UI prototypes awaiting user approval +- Issues #40-43: Frontend implementations blocked by #36-39 --- @@ -91,7 +96,7 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [ ] Create tool call routing ### 2.2 LLM Gateway MCP (Priority 1) -- [ ] Create FastMCP server structure +- [x] Create FastMCP server structure (`mcp-servers/llm-gateway/`) - [ ] Implement LiteLLM integration - [ ] Add model group routing - [ ] Implement failover chain @@ -99,6 +104,7 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [ ] Create token usage logging ### 2.3 Knowledge Base MCP (Priority 2) +- [x] Create server directory (`mcp-servers/knowledge-base/`) - [ ] Create pgvector schema for embeddings - [ ] Implement document ingestion pipeline - [ ] Create chunking strategies (code, markdown, text) @@ -107,6 +113,7 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [ ] Per-project collection isolation ### 2.4 Git MCP (Priority 3) +- [x] Create server directory (`mcp-servers/git-ops/`) - [ ] Create Git operations wrapper - [ ] Implement clone, commit, push operations - [ ] Add branch management @@ -115,6 +122,7 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [ ] Implement GitHub/GitLab adapters ### 2.5 Issues MCP (Priority 4) +- [x] Create server directory (`mcp-servers/issues/`) - [ ] Create issue sync service - [ ] Implement Gitea issue operations - [ ] Add GitHub issue adapter @@ -159,11 +167,11 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [ ] Add conversation threading ### 3.4 Background Task Integration -- [ ] Create Celery task wrappers +- [x] Create Celery task wrappers (`app/tasks/`) +- [x] Implement retry logic with exponential backoff - [ ] Implement progress reporting - [ ] Add task chaining for workflows - [ ] Create agent queue routing -- [ ] Implement task retry logic ### Deliverables - Agents can be spawned and communicate @@ -237,9 +245,12 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz - [ ] Decision explainer ### 5.4 Additional MCP Servers -- [ ] File System MCP -- [ ] Code Analysis MCP -- [ ] CI/CD MCP +- [x] File System MCP directory (`mcp-servers/file-system/`) +- [x] Code Analysis MCP directory (`mcp-servers/code-analysis/`) +- [x] CI/CD MCP directory (`mcp-servers/cicd/`) +- [ ] Implement File System MCP +- [ ] Implement Code Analysis MCP +- [ ] Implement CI/CD MCP ### Deliverables - Production-ready system @@ -278,6 +289,30 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz --- +## Current Progress Summary + +| Phase | Status | Completion | +|-------|--------|------------| +| Phase 0: Foundation | ✅ Complete | 100% | +| Phase 1: Core Platform | 🔄 In Progress | ~88% | +| Phase 2: MCP Integration | 📋 Not Started | 0% | +| Phase 3: Agent Orchestration | 📋 Not Started | 0% | +| Phase 4: Workflow Engine | 📋 Not Started | 0% | +| Phase 5: Advanced Features | 📋 Not Started | 0% | +| Phase 6: Polish & Launch | 📋 Not Started | 0% | + +### Phase 1 Breakdown +- Data Model: ✅ 100% (all entities, CRUD, migrations) +- API Layer: ✅ 100% (all endpoints implemented) +- Real-time: ✅ 100% (EventBus, SSE, client hooks) +- Frontend: 🔄 50% (prototypes done, implementations blocked) + +### Blocking Items +1. **UI Design Approvals (#36-39)**: Prototypes complete, awaiting user review +2. **Frontend Implementations (#40-43)**: Blocked by design approvals + +--- + ## Risk Register | Risk | Impact | Probability | Mitigation | @@ -310,12 +345,7 @@ This roadmap outlines the phased implementation approach for Syndarix, prioritiz ``` Phase 0 ─────▶ Phase 1 ─────▶ Phase 2 ─────▶ Phase 3 ─────▶ Phase 4 ─────▶ Phase 5 ─────▶ Phase 6 Foundation Core Platform MCP Integration Agent Orch Workflows Advanced Launch - │ - │ - Depends on: - - LLM Gateway - - Knowledge Base - - Real-time events + ✅ 🔄 88% 📋 📋 📋 📋 📋 ``` --- @@ -346,4 +376,4 @@ Foundation Core Platform MCP Integration Agent Orch Workflows Advan --- -*This roadmap will be refined as spikes complete and requirements evolve.* +*Last updated: 2025-12-30*