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.",
|
|
)
|