# 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 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 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, IssueStatus, SprintComplete, SprintCreate, SprintResponse, SprintStart, SprintStatus, SprintUpdate, SprintVelocity, ) logger = logging.getLogger(__name__) router = APIRouter() 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 # ============================================================================ # 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(f"{30 * RATE_MULTIPLIER}/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(f"{60 * RATE_MULTIPLIER}/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(f"{60 * RATE_MULTIPLIER}/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(f"{60 * RATE_MULTIPLIER}/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(f"{30 * RATE_MULTIPLIER}/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(f"{10 * RATE_MULTIPLIER}/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(f"{10 * RATE_MULTIPLIER}/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 @router.post( "/{sprint_id}/cancel", response_model=SprintResponse, summary="Cancel Sprint", description=""" Cancel a planned or active sprint. **Authentication**: Required (Bearer token) **Authorization**: Project owner or superuser **Business Rules**: - Only PLANNED or ACTIVE sprints can be cancelled - Issues in the sprint are NOT automatically removed - Cancelled sprints cannot be reactivated **Rate Limit**: 10 requests/minute """, operation_id="cancel_sprint", ) @limiter.limit(f"{10 * RATE_MULTIPLIER}/minute") async def cancel_sprint( request: Request, project_id: UUID, sprint_id: UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Any: """ Cancel a sprint. Cancellation is useful when a sprint needs to be abandoned. Issues remain in the sprint but can be moved to other sprints. """ # 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: cancelled_sprint = await sprint_crud.cancel_sprint(db, sprint_id=sprint_id) if not cancelled_sprint: raise NotFoundError( message=f"Sprint {sprint_id} not found", error_code=ErrorCode.NOT_FOUND, ) logger.info( f"User {current_user.id} cancelled sprint '{cancelled_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(cancelled_sprint) except ValueError as e: logger.warning(f"Failed to cancel sprint {sprint_id}: {e}") raise ValidationException( message=str(e), error_code=ErrorCode.OPERATION_FORBIDDEN, ) except Exception as e: logger.error(f"Error cancelling sprint {sprint_id}: {e!s}", exc_info=True) raise @router.delete( "/{sprint_id}", response_model=MessageResponse, summary="Delete Sprint", description=""" Delete a sprint permanently. **Authentication**: Required (Bearer token) **Authorization**: Project owner or superuser **Business Rules**: - Only PLANNED or CANCELLED sprints can be deleted - ACTIVE or COMPLETED sprints must be cancelled first - Issues in the sprint will have their sprint_id set to NULL **Rate Limit**: 10 requests/minute """, operation_id="delete_sprint", ) @limiter.limit(f"{10 * RATE_MULTIPLIER}/minute") async def delete_sprint( request: Request, project_id: UUID, sprint_id: UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Any: """ Delete a sprint permanently. Only PLANNED or CANCELLED sprints can be deleted to preserve historical data for completed sprints. """ # 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: Only PLANNED or CANCELLED sprints can be deleted if sprint.status not in [SprintStatus.PLANNED, SprintStatus.CANCELLED]: raise ValidationException( message=f"Cannot delete sprint with status '{sprint.status.value}'. " f"Only PLANNED or CANCELLED sprints can be deleted.", error_code=ErrorCode.OPERATION_FORBIDDEN, field="status", ) try: # Remove sprint assignment from all issues first await issue_crud.remove_sprint_from_issues(db, sprint_id=sprint_id) # Delete the sprint deleted = await sprint_crud.remove(db, id=sprint_id) if not deleted: raise NotFoundError( message=f"Sprint {sprint_id} not found", error_code=ErrorCode.NOT_FOUND, ) logger.info( f"User {current_user.id} deleted sprint '{sprint.name}' " f"(ID: {sprint_id}) from project {project_id}" ) return MessageResponse( success=True, message=f"Sprint '{sprint.name}' has been deleted.", ) except (NotFoundError, ValidationException): raise except Exception as e: logger.error(f"Error deleting 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(f"{60 * RATE_MULTIPLIER}/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(f"{30 * RATE_MULTIPLIER}/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, ) # Business rule: Cannot add closed issues to active/planned sprints if issue.status == IssueStatus.CLOSED and sprint.status in [ SprintStatus.PLANNED, SprintStatus.ACTIVE, ]: raise ValidationException( message="Cannot add closed issues to planned or active sprints. " "Reopen the issue first or use a different sprint.", error_code=ErrorCode.VALIDATION_ERROR, field="issue_id", ) 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 @router.delete( "/{sprint_id}/issues", response_model=MessageResponse, summary="Remove Issue from Sprint", description=""" Remove an issue from a sprint. **Authentication**: Required (Bearer token) **Authorization**: Project owner or superuser **Business Rules**: - Issue must currently be in this sprint - Cannot modify COMPLETED sprints (use cancel first) **Rate Limit**: 30 requests/minute """, operation_id="remove_issue_from_sprint", ) @limiter.limit(f"{30 * RATE_MULTIPLIER}/minute") async def remove_issue_from_sprint( request: Request, project_id: UUID, sprint_id: UUID, issue_id: UUID = Query(..., description="ID of the issue to remove from the sprint"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Any: """ Remove an issue from the sprint. The issue's sprint_id will be set to NULL. """ # 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 modify completed sprints if sprint.status == SprintStatus.COMPLETED: raise ValidationException( message="Cannot remove issues from a completed sprint. Cancel the sprint first.", error_code=ErrorCode.OPERATION_FORBIDDEN, ) # Verify issue exists and is in this sprint 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.sprint_id != sprint_id: raise ValidationException( message=f"Issue is not in sprint '{sprint.name}'", error_code=ErrorCode.VALIDATION_ERROR, ) try: # Remove the issue from sprint await issue_crud.remove_from_sprint(db, issue_id=issue_id) logger.info( f"User {current_user.id} removed issue {issue_id} from sprint {sprint_id}" ) return MessageResponse( success=True, message=f"Issue '{issue.title}' removed from sprint '{sprint.name}'", ) except Exception as e: logger.error( f"Error removing issue {issue_id} from 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(f"{60 * RATE_MULTIPLIER}/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