import uuid from datetime import date, datetime from typing import Any, Literal from fastapi import HTTPException from pydantic import BaseModel, Field, model_validator from api.classification import validate_classification from hub_core.schemas.managed_repo import ( RepoCreate as CoreRepoCreate, RepoPathRegister, RepoRead as CoreRepoRead, ) class ClassificationFields(BaseModel): category: str | None = None secondary_domains: list[str] | None = None capability_tags: list[str] | None = None business_stake: list[str] | None = None business_mechanics: list[str] | None = None classified_at: date | None = None classified_by: str | None = None standard_version: str | None = None def classification_fields_set(data: dict[str, Any]) -> bool: keys = ( "category", "secondary_domains", "capability_tags", "business_stake", "business_mechanics", "classified_at", "classified_by", "standard_version", ) return any(data.get(key) is not None for key in keys) def validate_repo_classification_fields( *, domain_slug: str, fields: dict[str, Any], require_complete: bool = False, ) -> dict[str, Any]: """Validate classification fields and return normalized values for persistence.""" if not classification_fields_set(fields) and not require_complete: return fields block = { "category": fields.get("category"), "domain": domain_slug, "secondary_domains": fields.get("secondary_domains") or [], "capability_tags": fields.get("capability_tags") or [], "business_stake": fields.get("business_stake") or [], "business_mechanics": fields.get("business_mechanics") or [], } if require_complete or fields.get("category") is not None: if block["category"] is None: raise HTTPException(status_code=422, detail="`category` is required when classification is provided") if classification_fields_set(fields) and block["category"] is not None: errors, warnings = validate_classification(block) if errors: raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings}) return fields class RepoCreate(CoreRepoCreate, ClassificationFields): topic_id: uuid.UUID | None = None @model_validator(mode="after") def validate_classification_on_create(self) -> "RepoCreate": validate_repo_classification_fields( domain_slug=self.domain_slug, fields=self.model_dump(), require_complete=classification_fields_set(self.model_dump()), ) return self class RepoUpdate(ClassificationFields): name: str | None = None local_path: str | None = None remote_url: str | None = None git_fingerprint: str | None = None description: str | None = None topic_id: uuid.UUID | None = None last_state_synced_at: datetime | None = None class RepoOnboardRequest(BaseModel): """Start scripted onboarding for a working copy that is visible to State Hub.""" domain_slug: str project_path: str agent_profile: Literal["claude-code", "codex"] = "codex" additional: bool = False class RepoOnboardResult(BaseModel): ok: bool repo_slug: str | None = None agent_profile: str command: list[str] stdout: str = "" stderr: str = "" class RepoRead(CoreRepoRead, ClassificationFields): topic_id: uuid.UUID | None = None sbom_source: str | None = None last_sbom_at: datetime | None = None last_state_synced_at: datetime | None = None created_at: datetime updated_at: datetime class DispatchTask(BaseModel): id: uuid.UUID title: str priority: str status: str needs_human: bool class DispatchWorkplan(BaseModel): id: uuid.UUID title: str status: str pending_tasks: list[DispatchTask] # Legacy alias DispatchWorkstream = DispatchWorkplan class PendingInterfaceChange(BaseModel): id: uuid.UUID title: str change_type: str interface_type: str origin_repo_slug: str affected_paths: list[str] planned_for: date | None published_at: datetime | None class ScopeIssueDetail(BaseModel): id: str label: str status: str detail: str missing_sections: list[str] = Field(default_factory=list) invalid_capability_blocks: list[dict[str, Any]] = Field(default_factory=list) needs_refresh_sections: list[str] = Field(default_factory=list) class RepoDispatch(BaseModel): repo_slug: str active_goal: dict[str, Any] | None active_workplans: list[DispatchWorkplan] human_interventions: list[DispatchTask] pending_interface_changes: list[PendingInterfaceChange] scope_needs_review: bool scope_issue_details: list[ScopeIssueDetail] last_state_synced_at: datetime | None @property def active_workstreams(self) -> list[DispatchWorkplan]: return self.active_workplans class RepoScopeHealth(BaseModel): repo_slug: str domain_slug: str | None = None local_path: str | None = None path_available: bool scope_needs_review: bool scope_issue_details: list[ScopeIssueDetail]