Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
1187 lines
35 KiB
Python
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
|