# app/schemas/syndarix/issue.py """ Pydantic schemas for Issue entity. """ from datetime import datetime from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from .enums import IssuePriority, IssueStatus, SyncStatus class IssueBase(BaseModel): """Base issue schema with common fields.""" title: str = Field(..., min_length=1, max_length=500) body: str = "" status: IssueStatus = IssueStatus.OPEN priority: IssuePriority = IssuePriority.MEDIUM labels: list[str] = Field(default_factory=list) story_points: int | None = Field(None, ge=0, le=100) @field_validator("title") @classmethod def validate_title(cls, v: str) -> str: """Validate issue title.""" if not v or v.strip() == "": raise ValueError("Issue title cannot be empty") return v.strip() @field_validator("labels") @classmethod def validate_labels(cls, v: list[str]) -> list[str]: """Validate and normalize labels.""" return [label.strip().lower() for label in v if label.strip()] class IssueCreate(IssueBase): """Schema for creating a new issue.""" project_id: UUID assigned_agent_id: UUID | None = None human_assignee: str | None = Field(None, max_length=255) sprint_id: UUID | None = None # External tracker fields (optional, for importing from external systems) external_tracker_type: Literal["gitea", "github", "gitlab"] | None = None external_issue_id: str | None = Field(None, max_length=255) remote_url: str | None = Field(None, max_length=1000) external_issue_number: int | None = None class IssueUpdate(BaseModel): """Schema for updating an issue.""" title: str | None = Field(None, min_length=1, max_length=500) body: str | None = None status: IssueStatus | None = None priority: IssuePriority | None = None labels: list[str] | None = None assigned_agent_id: UUID | None = None human_assignee: str | None = Field(None, max_length=255) sprint_id: UUID | None = None story_points: int | None = Field(None, ge=0, le=100) sync_status: SyncStatus | None = None @field_validator("title") @classmethod def validate_title(cls, v: str | None) -> str | None: """Validate issue title.""" if v is not None and (not v or v.strip() == ""): raise ValueError("Issue title cannot be empty") return v.strip() if v else v @field_validator("labels") @classmethod def validate_labels(cls, v: list[str] | None) -> list[str] | None: """Validate and normalize labels.""" if v is None: return v return [label.strip().lower() for label in v if label.strip()] class IssueClose(BaseModel): """Schema for closing an issue.""" resolution: str | None = None # Optional resolution note class IssueAssign(BaseModel): """Schema for assigning an issue.""" assigned_agent_id: UUID | None = None human_assignee: str | None = Field(None, max_length=255) @model_validator(mode="after") def validate_assignment(self) -> "IssueAssign": """Ensure only one type of assignee is set.""" if self.assigned_agent_id and self.human_assignee: raise ValueError( "Cannot assign to both an agent and a human. Choose one." ) return self class IssueSyncUpdate(BaseModel): """Schema for updating sync-related fields.""" sync_status: SyncStatus last_synced_at: datetime | None = None external_updated_at: datetime | None = None class IssueInDB(IssueBase): """Schema for issue in database.""" id: UUID project_id: UUID assigned_agent_id: UUID | None = None human_assignee: str | None = None sprint_id: UUID | None = None external_tracker_type: str | None = None external_issue_id: str | None = None remote_url: str | None = None external_issue_number: int | None = None sync_status: SyncStatus = SyncStatus.SYNCED last_synced_at: datetime | None = None external_updated_at: datetime | None = None closed_at: datetime | None = None created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True) class IssueResponse(BaseModel): """Schema for issue API responses.""" id: UUID project_id: UUID title: str body: str status: IssueStatus priority: IssuePriority labels: list[str] = Field(default_factory=list) assigned_agent_id: UUID | None = None human_assignee: str | None = None sprint_id: UUID | None = None story_points: int | None = None external_tracker_type: str | None = None external_issue_id: str | None = None remote_url: str | None = None external_issue_number: int | None = None sync_status: SyncStatus = SyncStatus.SYNCED last_synced_at: datetime | None = None external_updated_at: datetime | None = None closed_at: datetime | None = None created_at: datetime updated_at: datetime # Expanded fields from relationships project_name: str | None = None project_slug: str | None = None sprint_name: str | None = None assigned_agent_type_name: str | None = None model_config = ConfigDict(from_attributes=True) class IssueListResponse(BaseModel): """Schema for paginated issue list responses.""" issues: list[IssueResponse] total: int page: int page_size: int pages: int class IssueStats(BaseModel): """Schema for issue statistics.""" total: int open: int in_progress: int in_review: int blocked: int closed: int by_priority: dict[str, int] total_story_points: int | None = None completed_story_points: int | None = None