Files
fast-next-template/backend/app/api/routes/sprints.py
Felipe Cardoso acd18ff694 chore(backend): standardize multiline formatting across modules
Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
2026-01-03 01:35:18 +01:00

1187 lines
35 KiB
Python

# 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(
"/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
@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