# 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.crud.syndarix.sprint import sprint as sprint_crud from app.models.syndarix.enums import AgentStatus, 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", ) if agent.status == AgentStatus.TERMINATED: raise ValidationException( message="Cannot assign issue to a terminated agent", error_code=ErrorCode.VALIDATION_ERROR, field="assigned_agent_id", ) # Validate sprint if provided (IDOR prevention) if issue_in.sprint_id: sprint = await sprint_crud.get(db, id=issue_in.sprint_id) if not sprint: raise NotFoundError( message=f"Sprint {issue_in.sprint_id} not found", error_code=ErrorCode.NOT_FOUND, ) if sprint.project_id != project_id: raise ValidationException( message="Sprint does not belong to this project", error_code=ErrorCode.VALIDATION_ERROR, field="sprint_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 # ===== Issue Statistics Endpoint ===== # NOTE: This endpoint MUST be defined before /{issue_id} routes # to prevent FastAPI from trying to parse "stats" as a UUID @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 @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", ) if agent.status == AgentStatus.TERMINATED: raise ValidationException( message="Cannot assign issue to a terminated agent", error_code=ErrorCode.VALIDATION_ERROR, field="assigned_agent_id", ) # Validate sprint if being updated (IDOR prevention) if issue_in.sprint_id is not None: sprint = await sprint_crud.get(db, id=issue_in.sprint_id) if not sprint: raise NotFoundError( message=f"Sprint {issue_in.sprint_id} not found", error_code=ErrorCode.NOT_FOUND, ) if sprint.project_id != project_id: raise ValidationException( message="Sprint does not belong to this project", error_code=ErrorCode.VALIDATION_ERROR, field="sprint_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="Delete an issue permanently", 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: """ Delete an issue permanently. The issue will be permanently removed from the database. 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: issue_title = issue.title await issue_crud.remove(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", ) if agent.status == AgentStatus.TERMINATED: raise ValidationException( message="Cannot assign issue to a terminated agent", 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, ) @router.delete( "/projects/{project_id}/issues/{issue_id}/assignment", response_model=IssueResponse, summary="Unassign Issue", description=""" Remove agent/human assignment from an issue. **Authentication**: Required (Bearer token) **Authorization**: Project owner or superuser This clears both agent and human assignee fields. **Rate Limit**: 60 requests/minute """, operation_id="unassign_issue", ) @limiter.limit(f"{60 * RATE_MULTIPLIER}/minute") async def unassign_issue( request: Request, project_id: UUID, issue_id: UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Any: """ Remove assignment from an issue. Clears both assigned_agent_id and human_assignee fields. """ # 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 project (IDOR prevention) if issue.project_id != project_id: raise NotFoundError( message=f"Issue {issue_id} not found in project {project_id}", error_code=ErrorCode.NOT_FOUND, ) # Unassign the issue updated_issue = await issue_crud.unassign(db, issue_id=issue_id) if not updated_issue: raise NotFoundError( message=f"Issue {issue_id} not found", error_code=ErrorCode.NOT_FOUND, ) logger.info(f"User {current_user.email} unassigned issue {issue_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, ) # ===== 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.", )