forked from cardosofelipe/fast-next-template
Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
969 lines
30 KiB
Python
969 lines
30 KiB
Python
# app/api/routes/issues.py
|
|
"""
|
|
Issue CRUD API endpoints for Syndarix projects.
|
|
|
|
Provides endpoints for managing issues within projects, including:
|
|
- Create, read, update, delete operations
|
|
- Filtering by status, priority, labels, sprint, assigned agent
|
|
- Search across title and body
|
|
- Assignment to agents
|
|
- External issue tracker sync triggers
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request, status
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.dependencies.auth import get_current_user
|
|
from app.core.database import get_db
|
|
from app.core.exceptions import (
|
|
AuthorizationError,
|
|
NotFoundError,
|
|
ValidationException,
|
|
)
|
|
from app.crud.syndarix.agent_instance import agent_instance as agent_instance_crud
|
|
from app.crud.syndarix.issue import issue as issue_crud
|
|
from app.crud.syndarix.project import project as project_crud
|
|
from app.crud.syndarix.sprint import sprint as sprint_crud
|
|
from app.models.syndarix.enums import (
|
|
AgentStatus,
|
|
IssuePriority,
|
|
IssueStatus,
|
|
SprintStatus,
|
|
SyncStatus,
|
|
)
|
|
from app.models.user import User
|
|
from app.schemas.common import (
|
|
MessageResponse,
|
|
PaginatedResponse,
|
|
PaginationParams,
|
|
SortOrder,
|
|
create_pagination_meta,
|
|
)
|
|
from app.schemas.errors import ErrorCode
|
|
from app.schemas.syndarix.issue import (
|
|
IssueAssign,
|
|
IssueCreate,
|
|
IssueResponse,
|
|
IssueStats,
|
|
IssueUpdate,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize limiter for this router
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
# Use higher rate limits in test environment
|
|
IS_TEST = os.getenv("IS_TEST", "False") == "True"
|
|
RATE_MULTIPLIER = 100 if IS_TEST else 1
|
|
|
|
|
|
async def verify_project_ownership(
|
|
db: AsyncSession,
|
|
project_id: UUID,
|
|
user: User,
|
|
) -> None:
|
|
"""
|
|
Verify that the user owns the project or is a superuser.
|
|
|
|
Args:
|
|
db: Database session
|
|
project_id: Project UUID to verify
|
|
user: Current authenticated user
|
|
|
|
Raises:
|
|
NotFoundError: If project does not exist
|
|
AuthorizationError: If user does not own the project
|
|
"""
|
|
project = await project_crud.get(db, id=project_id)
|
|
if not project:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
if not user.is_superuser and project.owner_id != user.id:
|
|
raise AuthorizationError(
|
|
message="You do not have access to this project",
|
|
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
|
|
)
|
|
|
|
|
|
def _build_issue_response(
|
|
issue: Any,
|
|
project_name: str | None = None,
|
|
project_slug: str | None = None,
|
|
sprint_name: str | None = None,
|
|
assigned_agent_type_name: str | None = None,
|
|
) -> IssueResponse:
|
|
"""
|
|
Build an IssueResponse from an Issue model instance.
|
|
|
|
Args:
|
|
issue: Issue model instance
|
|
project_name: Optional project name from relationship
|
|
project_slug: Optional project slug from relationship
|
|
sprint_name: Optional sprint name from relationship
|
|
assigned_agent_type_name: Optional agent type name from relationship
|
|
|
|
Returns:
|
|
IssueResponse schema instance
|
|
"""
|
|
return IssueResponse(
|
|
id=issue.id,
|
|
project_id=issue.project_id,
|
|
title=issue.title,
|
|
body=issue.body,
|
|
status=issue.status,
|
|
priority=issue.priority,
|
|
labels=issue.labels or [],
|
|
assigned_agent_id=issue.assigned_agent_id,
|
|
human_assignee=issue.human_assignee,
|
|
sprint_id=issue.sprint_id,
|
|
story_points=issue.story_points,
|
|
external_tracker_type=issue.external_tracker_type,
|
|
external_issue_id=issue.external_issue_id,
|
|
remote_url=issue.remote_url,
|
|
external_issue_number=issue.external_issue_number,
|
|
sync_status=issue.sync_status,
|
|
last_synced_at=issue.last_synced_at,
|
|
external_updated_at=issue.external_updated_at,
|
|
closed_at=issue.closed_at,
|
|
created_at=issue.created_at,
|
|
updated_at=issue.updated_at,
|
|
project_name=project_name,
|
|
project_slug=project_slug,
|
|
sprint_name=sprint_name,
|
|
assigned_agent_type_name=assigned_agent_type_name,
|
|
)
|
|
|
|
|
|
# ===== Issue CRUD Endpoints =====
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/issues",
|
|
response_model=IssueResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create Issue",
|
|
description="Create a new issue in a project",
|
|
operation_id="create_issue",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def create_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_in: IssueCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Create a new issue within a project.
|
|
|
|
The user must own the project or be a superuser.
|
|
The project_id in the path takes precedence over any project_id in the body.
|
|
|
|
Args:
|
|
request: FastAPI request object (for rate limiting)
|
|
project_id: UUID of the project to create the issue in
|
|
issue_in: Issue creation data
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Created issue with full details
|
|
|
|
Raises:
|
|
NotFoundError: If project not found
|
|
AuthorizationError: If user lacks access
|
|
ValidationException: If assigned agent not in project
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Override project_id from path
|
|
issue_in.project_id = project_id
|
|
|
|
# Validate assigned agent if provided
|
|
if issue_in.assigned_agent_id:
|
|
agent = await agent_instance_crud.get(db, id=issue_in.assigned_agent_id)
|
|
if not agent:
|
|
raise NotFoundError(
|
|
message=f"Agent instance {issue_in.assigned_agent_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
if agent.project_id != project_id:
|
|
raise ValidationException(
|
|
message="Agent instance does not belong to this project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="assigned_agent_id",
|
|
)
|
|
if agent.status == AgentStatus.TERMINATED:
|
|
raise ValidationException(
|
|
message="Cannot assign issue to a terminated agent",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="assigned_agent_id",
|
|
)
|
|
|
|
# Validate sprint if provided (IDOR prevention)
|
|
if issue_in.sprint_id:
|
|
sprint = await sprint_crud.get(db, id=issue_in.sprint_id)
|
|
if not sprint:
|
|
raise NotFoundError(
|
|
message=f"Sprint {issue_in.sprint_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
if sprint.project_id != project_id:
|
|
raise ValidationException(
|
|
message="Sprint does not belong to this project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="sprint_id",
|
|
)
|
|
|
|
try:
|
|
issue = await issue_crud.create(db, obj_in=issue_in)
|
|
logger.info(
|
|
f"User {current_user.email} created issue '{issue.title}' "
|
|
f"in project {project_id}"
|
|
)
|
|
|
|
# Get project details for response
|
|
project = await project_crud.get(db, id=project_id)
|
|
|
|
return _build_issue_response(
|
|
issue,
|
|
project_name=project.name if project else None,
|
|
project_slug=project.slug if project else None,
|
|
)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Failed to create issue: {e!s}")
|
|
raise ValidationException(
|
|
message=str(e),
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error creating issue: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/issues",
|
|
response_model=PaginatedResponse[IssueResponse],
|
|
summary="List Issues",
|
|
description="Get paginated list of issues in a project with filtering",
|
|
operation_id="list_issues",
|
|
)
|
|
@limiter.limit(f"{120 * RATE_MULTIPLIER}/minute")
|
|
async def list_issues(
|
|
request: Request,
|
|
project_id: UUID,
|
|
pagination: PaginationParams = Depends(),
|
|
status_filter: IssueStatus | None = Query(
|
|
None, alias="status", description="Filter by issue status"
|
|
),
|
|
priority: IssuePriority | None = Query(None, description="Filter by priority"),
|
|
labels: list[str] | None = Query(
|
|
None, description="Filter by labels (comma-separated)"
|
|
),
|
|
sprint_id: UUID | None = Query(None, description="Filter by sprint ID"),
|
|
assigned_agent_id: UUID | None = Query(
|
|
None, description="Filter by assigned agent ID"
|
|
),
|
|
sync_status: SyncStatus | None = Query(None, description="Filter by sync status"),
|
|
search: str | None = Query(
|
|
None, min_length=1, max_length=100, description="Search in title and body"
|
|
),
|
|
sort_by: str = Query(
|
|
"created_at",
|
|
description="Field to sort by (created_at, updated_at, priority, status, title)",
|
|
),
|
|
sort_order: SortOrder = Query(SortOrder.DESC, description="Sort order"),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
List issues in a project with comprehensive filtering options.
|
|
|
|
Supports filtering by:
|
|
- status: Issue status (open, in_progress, in_review, blocked, closed)
|
|
- priority: Issue priority (low, medium, high, critical)
|
|
- labels: Match issues containing any of the provided labels
|
|
- sprint_id: Issues in a specific sprint
|
|
- assigned_agent_id: Issues assigned to a specific agent
|
|
- sync_status: External tracker sync status
|
|
- search: Full-text search in title and body
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
pagination: Pagination parameters
|
|
status_filter: Optional status filter
|
|
priority: Optional priority filter
|
|
labels: Optional labels filter
|
|
sprint_id: Optional sprint filter
|
|
assigned_agent_id: Optional agent assignment filter
|
|
sync_status: Optional sync status filter
|
|
search: Optional search query
|
|
sort_by: Field to sort by
|
|
sort_order: Sort direction
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Paginated list of issues matching filters
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
try:
|
|
# Get filtered issues
|
|
issues, total = await issue_crud.get_by_project(
|
|
db,
|
|
project_id=project_id,
|
|
status=status_filter,
|
|
priority=priority,
|
|
sprint_id=sprint_id,
|
|
assigned_agent_id=assigned_agent_id,
|
|
labels=labels,
|
|
search=search,
|
|
skip=pagination.offset,
|
|
limit=pagination.limit,
|
|
sort_by=sort_by,
|
|
sort_order=sort_order.value,
|
|
)
|
|
|
|
# Build response objects
|
|
issue_responses = [_build_issue_response(issue) for issue in issues]
|
|
|
|
pagination_meta = create_pagination_meta(
|
|
total=total,
|
|
page=pagination.page,
|
|
limit=pagination.limit,
|
|
items_count=len(issue_responses),
|
|
)
|
|
|
|
return PaginatedResponse(data=issue_responses, pagination=pagination_meta)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error listing issues for project {project_id}: {e!s}", exc_info=True
|
|
)
|
|
raise
|
|
|
|
|
|
# ===== Issue Statistics Endpoint =====
|
|
# NOTE: This endpoint MUST be defined before /{issue_id} routes
|
|
# to prevent FastAPI from trying to parse "stats" as a UUID
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/issues/stats",
|
|
response_model=IssueStats,
|
|
summary="Get Issue Statistics",
|
|
description="Get aggregated issue statistics for a project",
|
|
operation_id="get_issue_stats",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def get_issue_stats(
|
|
request: Request,
|
|
project_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Get aggregated statistics for issues in a project.
|
|
|
|
Returns counts by status and priority, along with story point totals.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Issue statistics including counts by status/priority and story points
|
|
|
|
Raises:
|
|
NotFoundError: If project not found
|
|
AuthorizationError: If user lacks access
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
try:
|
|
stats = await issue_crud.get_project_stats(db, project_id=project_id)
|
|
return IssueStats(**stats)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error getting issue stats for project {project_id}: {e!s}",
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/issues/{issue_id}",
|
|
response_model=IssueResponse,
|
|
summary="Get Issue",
|
|
description="Get detailed information about a specific issue",
|
|
operation_id="get_issue",
|
|
)
|
|
@limiter.limit(f"{120 * RATE_MULTIPLIER}/minute")
|
|
async def get_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Get detailed information about a specific issue.
|
|
|
|
Returns the issue with expanded relationship data including
|
|
project name, sprint name, and assigned agent type name.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
issue_id: Issue UUID
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Issue details with relationship data
|
|
|
|
Raises:
|
|
NotFoundError: If project or issue not found
|
|
AuthorizationError: If user lacks access
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Get issue with details
|
|
issue_data = await issue_crud.get_with_details(db, issue_id=issue_id)
|
|
|
|
if not issue_data:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
issue = issue_data["issue"]
|
|
|
|
# Verify issue belongs to the project
|
|
if issue.project_id != project_id:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found in project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
return _build_issue_response(
|
|
issue,
|
|
project_name=issue_data.get("project_name"),
|
|
project_slug=issue_data.get("project_slug"),
|
|
sprint_name=issue_data.get("sprint_name"),
|
|
assigned_agent_type_name=issue_data.get("assigned_agent_type_name"),
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/projects/{project_id}/issues/{issue_id}",
|
|
response_model=IssueResponse,
|
|
summary="Update Issue",
|
|
description="Update an existing issue",
|
|
operation_id="update_issue",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def update_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_id: UUID,
|
|
issue_in: IssueUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Update an existing issue.
|
|
|
|
All fields are optional - only provided fields will be updated.
|
|
Validates that assigned agent belongs to the same project.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
issue_id: Issue UUID
|
|
issue_in: Fields to update
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Updated issue details
|
|
|
|
Raises:
|
|
NotFoundError: If project or issue not found
|
|
AuthorizationError: If user lacks access
|
|
ValidationException: If validation fails
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Get existing issue
|
|
issue = await issue_crud.get(db, id=issue_id)
|
|
if not issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Verify issue belongs to the project
|
|
if issue.project_id != project_id:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found in project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Validate assigned agent if being updated
|
|
if issue_in.assigned_agent_id is not None:
|
|
agent = await agent_instance_crud.get(db, id=issue_in.assigned_agent_id)
|
|
if not agent:
|
|
raise NotFoundError(
|
|
message=f"Agent instance {issue_in.assigned_agent_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
if agent.project_id != project_id:
|
|
raise ValidationException(
|
|
message="Agent instance does not belong to this project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="assigned_agent_id",
|
|
)
|
|
if agent.status == AgentStatus.TERMINATED:
|
|
raise ValidationException(
|
|
message="Cannot assign issue to a terminated agent",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="assigned_agent_id",
|
|
)
|
|
|
|
# Validate sprint if being updated (IDOR prevention and status validation)
|
|
if issue_in.sprint_id is not None:
|
|
sprint = await sprint_crud.get(db, id=issue_in.sprint_id)
|
|
if not sprint:
|
|
raise NotFoundError(
|
|
message=f"Sprint {issue_in.sprint_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
if sprint.project_id != project_id:
|
|
raise ValidationException(
|
|
message="Sprint does not belong to this project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="sprint_id",
|
|
)
|
|
# Cannot add issues to completed or 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.VALIDATION_ERROR,
|
|
field="sprint_id",
|
|
)
|
|
|
|
try:
|
|
updated_issue = await issue_crud.update(db, db_obj=issue, obj_in=issue_in)
|
|
logger.info(
|
|
f"User {current_user.email} updated issue {issue_id} in project {project_id}"
|
|
)
|
|
|
|
# Get full details for response
|
|
issue_data = await issue_crud.get_with_details(db, issue_id=issue_id)
|
|
|
|
return _build_issue_response(
|
|
updated_issue,
|
|
project_name=issue_data.get("project_name") if issue_data else None,
|
|
project_slug=issue_data.get("project_slug") if issue_data else None,
|
|
sprint_name=issue_data.get("sprint_name") if issue_data else None,
|
|
assigned_agent_type_name=issue_data.get("assigned_agent_type_name")
|
|
if issue_data
|
|
else None,
|
|
)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Failed to update issue {issue_id}: {e!s}")
|
|
raise ValidationException(
|
|
message=str(e),
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error updating issue {issue_id}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.delete(
|
|
"/projects/{project_id}/issues/{issue_id}",
|
|
response_model=MessageResponse,
|
|
summary="Delete Issue",
|
|
description="Delete an issue permanently",
|
|
operation_id="delete_issue",
|
|
)
|
|
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
|
async def delete_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Delete an issue permanently.
|
|
|
|
The issue will be permanently removed from the database.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
issue_id: Issue UUID
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
|
|
Raises:
|
|
NotFoundError: If project or issue not found
|
|
AuthorizationError: If user lacks access
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Get existing issue
|
|
issue = await issue_crud.get(db, id=issue_id)
|
|
if not issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Verify issue belongs to the project
|
|
if issue.project_id != project_id:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found in project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
try:
|
|
issue_title = issue.title
|
|
await issue_crud.remove(db, id=issue_id)
|
|
logger.info(
|
|
f"User {current_user.email} deleted issue {issue_id} "
|
|
f"('{issue_title}') from project {project_id}"
|
|
)
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message=f"Issue '{issue_title}' has been deleted",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deleting issue {issue_id}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
# ===== Issue Assignment Endpoint =====
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/issues/{issue_id}/assign",
|
|
response_model=IssueResponse,
|
|
summary="Assign Issue",
|
|
description="Assign an issue to an agent or human",
|
|
operation_id="assign_issue",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def assign_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_id: UUID,
|
|
assignment: IssueAssign,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Assign an issue to an agent or human.
|
|
|
|
Only one type of assignment is allowed at a time:
|
|
- assigned_agent_id: Assign to an AI agent instance
|
|
- human_assignee: Assign to a human (name/email string)
|
|
|
|
To unassign, pass both as null/None.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
issue_id: Issue UUID
|
|
assignment: Assignment data
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Updated issue with assignment
|
|
|
|
Raises:
|
|
NotFoundError: If project, issue, or agent not found
|
|
AuthorizationError: If user lacks access
|
|
ValidationException: If agent not in project
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Get existing issue
|
|
issue = await issue_crud.get(db, id=issue_id)
|
|
if not issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Verify issue belongs to the project
|
|
if issue.project_id != project_id:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found in project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Process assignment based on type
|
|
if assignment.assigned_agent_id:
|
|
# Validate agent exists and belongs to project
|
|
agent = await agent_instance_crud.get(db, id=assignment.assigned_agent_id)
|
|
if not agent:
|
|
raise NotFoundError(
|
|
message=f"Agent instance {assignment.assigned_agent_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
if agent.project_id != project_id:
|
|
raise ValidationException(
|
|
message="Agent instance does not belong to this project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="assigned_agent_id",
|
|
)
|
|
if agent.status == AgentStatus.TERMINATED:
|
|
raise ValidationException(
|
|
message="Cannot assign issue to a terminated agent",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="assigned_agent_id",
|
|
)
|
|
|
|
updated_issue = await issue_crud.assign_to_agent(
|
|
db, issue_id=issue_id, agent_id=assignment.assigned_agent_id
|
|
)
|
|
logger.info(
|
|
f"User {current_user.email} assigned issue {issue_id} to agent {agent.name}"
|
|
)
|
|
|
|
elif assignment.human_assignee:
|
|
updated_issue = await issue_crud.assign_to_human(
|
|
db, issue_id=issue_id, human_assignee=assignment.human_assignee
|
|
)
|
|
logger.info(
|
|
f"User {current_user.email} assigned issue {issue_id} "
|
|
f"to human '{assignment.human_assignee}'"
|
|
)
|
|
|
|
else:
|
|
# Unassign - clear both agent and human
|
|
updated_issue = await issue_crud.assign_to_agent(
|
|
db, issue_id=issue_id, agent_id=None
|
|
)
|
|
logger.info(f"User {current_user.email} unassigned issue {issue_id}")
|
|
|
|
if not updated_issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Get full details for response
|
|
issue_data = await issue_crud.get_with_details(db, issue_id=issue_id)
|
|
|
|
return _build_issue_response(
|
|
updated_issue,
|
|
project_name=issue_data.get("project_name") if issue_data else None,
|
|
project_slug=issue_data.get("project_slug") if issue_data else None,
|
|
sprint_name=issue_data.get("sprint_name") if issue_data else None,
|
|
assigned_agent_type_name=issue_data.get("assigned_agent_type_name")
|
|
if issue_data
|
|
else None,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/projects/{project_id}/issues/{issue_id}/assignment",
|
|
response_model=IssueResponse,
|
|
summary="Unassign Issue",
|
|
description="""
|
|
Remove agent/human assignment from an issue.
|
|
|
|
**Authentication**: Required (Bearer token)
|
|
**Authorization**: Project owner or superuser
|
|
|
|
This clears both agent and human assignee fields.
|
|
|
|
**Rate Limit**: 60 requests/minute
|
|
""",
|
|
operation_id="unassign_issue",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def unassign_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Remove assignment from an issue.
|
|
|
|
Clears both assigned_agent_id and human_assignee fields.
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Get existing issue
|
|
issue = await issue_crud.get(db, id=issue_id)
|
|
if not issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Verify issue belongs to project (IDOR prevention)
|
|
if issue.project_id != project_id:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found in project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Unassign the issue
|
|
updated_issue = await issue_crud.unassign(db, issue_id=issue_id)
|
|
|
|
if not updated_issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
logger.info(f"User {current_user.email} unassigned issue {issue_id}")
|
|
|
|
# Get full details for response
|
|
issue_data = await issue_crud.get_with_details(db, issue_id=issue_id)
|
|
|
|
return _build_issue_response(
|
|
updated_issue,
|
|
project_name=issue_data.get("project_name") if issue_data else None,
|
|
project_slug=issue_data.get("project_slug") if issue_data else None,
|
|
sprint_name=issue_data.get("sprint_name") if issue_data else None,
|
|
assigned_agent_type_name=issue_data.get("assigned_agent_type_name")
|
|
if issue_data
|
|
else None,
|
|
)
|
|
|
|
|
|
# ===== Issue Sync Endpoint =====
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/issues/{issue_id}/sync",
|
|
response_model=MessageResponse,
|
|
summary="Trigger Issue Sync",
|
|
description="Trigger synchronization with external issue tracker",
|
|
operation_id="sync_issue",
|
|
)
|
|
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
|
async def sync_issue(
|
|
request: Request,
|
|
project_id: UUID,
|
|
issue_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Trigger synchronization of an issue with its external tracker.
|
|
|
|
This endpoint queues a sync task for the issue. The actual synchronization
|
|
happens asynchronously via Celery.
|
|
|
|
Prerequisites:
|
|
- Issue must have external_tracker_type configured
|
|
- Project must have integration settings for the tracker
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
project_id: Project UUID
|
|
issue_id: Issue UUID
|
|
current_user: Authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Message indicating sync has been triggered
|
|
|
|
Raises:
|
|
NotFoundError: If project or issue not found
|
|
AuthorizationError: If user lacks access
|
|
ValidationException: If issue has no external tracker
|
|
"""
|
|
# Verify project access
|
|
await verify_project_ownership(db, project_id, current_user)
|
|
|
|
# Get existing issue
|
|
issue = await issue_crud.get(db, id=issue_id)
|
|
if not issue:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Verify issue belongs to the project
|
|
if issue.project_id != project_id:
|
|
raise NotFoundError(
|
|
message=f"Issue {issue_id} not found in project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
# Check if issue has external tracker configured
|
|
if not issue.external_tracker_type:
|
|
raise ValidationException(
|
|
message="Issue does not have an external tracker configured",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="external_tracker_type",
|
|
)
|
|
|
|
# Update sync status to pending
|
|
await issue_crud.update_sync_status(
|
|
db,
|
|
issue_id=issue_id,
|
|
sync_status=SyncStatus.PENDING,
|
|
)
|
|
|
|
# TODO: Queue Celery task for actual sync
|
|
# When Celery is set up, this will be:
|
|
# from app.tasks.sync import sync_issue_task
|
|
# sync_issue_task.delay(str(issue_id))
|
|
|
|
logger.info(
|
|
f"User {current_user.email} triggered sync for issue {issue_id} "
|
|
f"(tracker: {issue.external_tracker_type})"
|
|
)
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message=f"Sync triggered for issue '{issue.title}'. "
|
|
f"Status will update when complete.",
|
|
)
|