# [DEF:CleanReleaseV2Api:Module] # @COMPLEXITY: 4 # @PURPOSE: Redesigned clean release API for headless candidate lifecycle. # @LAYER: UI (API) # @RELATION: DEPENDS_ON -> [CleanReleaseRepository] # @RELATION: CALLS -> [approve_candidate] # @RELATION: CALLS -> [publish_candidate] # @PRE: Clean release repository dependency is available for candidate lifecycle endpoints. # @POST: Candidate registration, approval, publication, and revocation routes are registered without behavior changes. # @SIDE_EFFECT: Persists candidate lifecycle state through clean release services and repository adapters. from fastapi import APIRouter, Depends, HTTPException, status from typing import List, Dict, Any from datetime import datetime, timezone from ...services.clean_release.approval_service import ( approve_candidate, reject_candidate, ) from ...services.clean_release.publication_service import ( publish_candidate, revoke_publication, ) from ...services.clean_release.repository import CleanReleaseRepository from ...dependencies import get_clean_release_repository from ...services.clean_release.enums import CandidateStatus from ...models.clean_release import ( ReleaseCandidate, CandidateArtifact, DistributionManifest, ) from ...services.clean_release.dto import CandidateDTO, ManifestDTO router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"]) # [DEF:ApprovalRequest:Class] # @COMPLEXITY: 1 # @PURPOSE: Schema for approval request payload. class ApprovalRequest(dict): pass # [/DEF:ApprovalRequest:Class] # [DEF:PublishRequest:Class] # @COMPLEXITY: 1 # @PURPOSE: Schema for publication request payload. class PublishRequest(dict): pass # [/DEF:PublishRequest:Class] # [DEF:RevokeRequest:Class] # @COMPLEXITY: 1 # @PURPOSE: Schema for revocation request payload. class RevokeRequest(dict): pass # [/DEF:RevokeRequest:Class] # [DEF:register_candidate:Function] # @COMPLEXITY: 3 # @PURPOSE: Register a new release candidate. # @PRE: Payload contains required fields (id, version, source_snapshot_ref, created_by). # @POST: Candidate is saved in repository. # @RETURN: CandidateDTO # @RELATION: DEPENDS_ON -> [CleanReleaseRepository] # @RELATION: DEPENDS_ON -> [clean_release_dto] @router.post( "/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED ) async def register_candidate( payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = ReleaseCandidate( id=payload["id"], version=payload["version"], source_snapshot_ref=payload["source_snapshot_ref"], created_by=payload["created_by"], created_at=datetime.now(timezone.utc), status=CandidateStatus.DRAFT.value, ) repository.save_candidate(candidate) return CandidateDTO( id=candidate.id, version=candidate.version, source_snapshot_ref=candidate.source_snapshot_ref, created_at=candidate.created_at, created_by=candidate.created_by, status=CandidateStatus(candidate.status), ) # [/DEF:register_candidate:Function] # [DEF:import_artifacts:Function] # @COMPLEXITY: 3 # @PURPOSE: Associate artifacts with a release candidate. # @PRE: Candidate exists. # @POST: Artifacts are processed (placeholder). # @RELATION: DEPENDS_ON -> [CleanReleaseRepository] @router.post("/candidates/{candidate_id}/artifacts") async def import_artifacts( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = repository.get_candidate(candidate_id) if not candidate: raise HTTPException(status_code=404, detail="Candidate not found") for art_data in payload.get("artifacts", []): artifact = CandidateArtifact( id=art_data["id"], candidate_id=candidate_id, path=art_data["path"], sha256=art_data["sha256"], size=art_data["size"], ) # In a real repo we'd have save_artifact # repository.save_artifact(artifact) pass return {"status": "success"} # [/DEF:import_artifacts:Function] # [DEF:build_manifest:Function] # @COMPLEXITY: 3 # @PURPOSE: Generate distribution manifest for a candidate. # @PRE: Candidate exists. # @POST: Manifest is created and saved. # @RETURN: ManifestDTO # @RELATION: DEPENDS_ON -> [CleanReleaseRepository] @router.post( "/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED, ) async def build_manifest( candidate_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = repository.get_candidate(candidate_id) if not candidate: raise HTTPException(status_code=404, detail="Candidate not found") manifest = DistributionManifest( id=f"manifest-{candidate_id}", candidate_id=candidate_id, manifest_version=1, manifest_digest="hash-123", artifacts_digest="art-hash-123", created_by="system", created_at=datetime.now(timezone.utc), source_snapshot_ref=candidate.source_snapshot_ref, content_json={"items": [], "summary": {}}, ) repository.save_manifest(manifest) return ManifestDTO( id=manifest.id, candidate_id=manifest.candidate_id, manifest_version=manifest.manifest_version, manifest_digest=manifest.manifest_digest, artifacts_digest=manifest.artifacts_digest, created_at=manifest.created_at, created_by=manifest.created_by, source_snapshot_ref=manifest.source_snapshot_ref, content_json=manifest.content_json, ) # [/DEF:build_manifest:Function] # [DEF:approve_candidate_endpoint:Function] # @COMPLEXITY: 3 # @PURPOSE: Endpoint to record candidate approval. # @RELATION: CALLS -> [approve_candidate] @router.post("/candidates/{candidate_id}/approve") async def approve_candidate_endpoint( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: decision = approve_candidate( repository=repository, candidate_id=candidate_id, report_id=str(payload["report_id"]), decided_by=str(payload["decided_by"]), comment=payload.get("comment"), ) except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"} ) return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} # [/DEF:approve_candidate_endpoint:Function] # [DEF:reject_candidate_endpoint:Function] # @COMPLEXITY: 3 # @PURPOSE: Endpoint to record candidate rejection. # @RELATION: CALLS -> [reject_candidate] @router.post("/candidates/{candidate_id}/reject") async def reject_candidate_endpoint( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: decision = reject_candidate( repository=repository, candidate_id=candidate_id, report_id=str(payload["report_id"]), decided_by=str(payload["decided_by"]), comment=payload.get("comment"), ) except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"} ) return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} # [/DEF:reject_candidate_endpoint:Function] # [DEF:publish_candidate_endpoint:Function] # @COMPLEXITY: 3 # @PURPOSE: Endpoint to publish an approved candidate. # @RELATION: CALLS -> [publish_candidate] @router.post("/candidates/{candidate_id}/publish") async def publish_candidate_endpoint( candidate_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: publication = publish_candidate( repository=repository, candidate_id=candidate_id, report_id=str(payload["report_id"]), published_by=str(payload["published_by"]), target_channel=str(payload["target_channel"]), publication_ref=payload.get("publication_ref"), ) except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"}, ) return { "status": "ok", "publication": { "id": publication.id, "candidate_id": publication.candidate_id, "report_id": publication.report_id, "published_by": publication.published_by, "published_at": publication.published_at.isoformat() if publication.published_at else None, "target_channel": publication.target_channel, "publication_ref": publication.publication_ref, "status": publication.status, }, } # [/DEF:publish_candidate_endpoint:Function] # [DEF:revoke_publication_endpoint:Function] # @COMPLEXITY: 3 # @PURPOSE: Endpoint to revoke a previous publication. # @RELATION: CALLS -> [revoke_publication] @router.post("/publications/{publication_id}/revoke") async def revoke_publication_endpoint( publication_id: str, payload: Dict[str, Any], repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: publication = revoke_publication( repository=repository, publication_id=publication_id, revoked_by=str(payload["revoked_by"]), comment=payload.get("comment"), ) except Exception as exc: # noqa: BLE001 raise HTTPException( status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"}, ) return { "status": "ok", "publication": { "id": publication.id, "candidate_id": publication.candidate_id, "report_id": publication.report_id, "published_by": publication.published_by, "published_at": publication.published_at.isoformat() if publication.published_at else None, "target_channel": publication.target_channel, "publication_ref": publication.publication_ref, "status": publication.status, }, } # [/DEF:revoke_publication_endpoint:Function] # [/DEF:CleanReleaseV2Api:Module]