"""CI/CD runs, releases, production deploy: /organizations/{org}/delivery/*. Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each route its full path (no prefix is applied here besides ``/api``). **Who may call what.** Reading a build — its steps, its logs, the demo URL — is an ordinary thing for a member of the organization to do, so the GET routes need only membership. Everything that *causes* work (re-run, cancel, rollback) or touches production requires `projects.manage`, which is org admin and above. The production deploy endpoint (Phase 5) additionally demands the org slug typed back as a confirmation, because it is the one irreversible action in the product. """ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.application.services.delivery_service import DeliveryService from app.core.database import get_session from app.domain.identity.models import CAN_MANAGE_PROJECTS from app.interfaces.http.deps import ( OrgContext, client_meta, get_delivery_service, get_org_context, ) router = APIRouter(prefix="/organizations/{org_id}/delivery", tags=["delivery"]) # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class PipelineStepOut(BaseModel): id: str index: int name: str status: str exit_code: int | None log_tail: str duration_ms: int class PipelineRunOut(BaseModel): id: str project_id: str branch: str sha: str trigger: str status: str error_summary: str started_at: datetime | None finished_at: datetime | None created_at: datetime class PipelineRunDetailOut(PipelineRunOut): steps: list[PipelineStepOut] class ReleaseOut(BaseModel): id: str project_id: str run_id: str sha: str is_demo_live: bool created_at: datetime class RuntimeOut(BaseModel): project_id: str demo_host: str demo_url: str host_port: int | None status: str current_release_id: str | None updated_at: datetime class ManualRunRequest(BaseModel): project_id: str = Field(min_length=1, max_length=32) class RollbackRequest(BaseModel): project_id: str = Field(min_length=1, max_length=32) release_id: str | None = Field(default=None, max_length=32) def _run_out(run) -> PipelineRunOut: # noqa: ANN001 - a domain dataclass return PipelineRunOut( id=run.id, project_id=run.project_id, branch=run.branch, sha=run.sha, trigger=run.trigger, status=run.status, error_summary=run.error_summary, started_at=run.started_at, finished_at=run.finished_at, created_at=run.created_at, ) def _release_out(release) -> ReleaseOut: # noqa: ANN001 return ReleaseOut( id=release.id, project_id=release.project_id, run_id=release.run_id, sha=release.sha, is_demo_live=release.is_demo_live, created_at=release.created_at, ) def _runtime_out(runtime) -> RuntimeOut: # noqa: ANN001 return RuntimeOut( project_id=runtime.project_id, demo_host=runtime.demo_host, demo_url=runtime.demo_url, host_port=runtime.host_port, status=runtime.status, current_release_id=runtime.current_release_id, updated_at=runtime.updated_at, ) # --------------------------------------------------------------------------- # Runs # --------------------------------------------------------------------------- @router.get("/runs", response_model=list[PipelineRunOut]) async def list_runs( project_id: str = Query(min_length=1), context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), ) -> list[PipelineRunOut]: runs = await service.list_runs( organization_id=context.org_id, project_id=project_id, limit=limit, offset=offset ) return [_run_out(r) for r in runs] @router.get("/runs/{run_id}", response_model=PipelineRunDetailOut) async def get_run( run_id: str, context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), ) -> PipelineRunDetailOut: run, steps = await service.get_run_detail(organization_id=context.org_id, run_id=run_id) return PipelineRunDetailOut( **_run_out(run).model_dump(), steps=[ PipelineStepOut( id=s.id, index=s.index, name=s.name, status=s.status, exit_code=s.exit_code, log_tail=s.log_tail, duration_ms=s.duration_ms, ) for s in steps ], ) @router.post("/runs", response_model=PipelineRunOut, status_code=status.HTTP_201_CREATED) async def start_run( payload: ManualRunRequest, request: Request, context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), session: AsyncSession = Depends(get_session), ) -> PipelineRunOut: """Re-run the current `dev` head. Never a production deploy.""" context.require(CAN_MANAGE_PROJECTS) run = await service.request_manual_run( organization_id=context.org_id, project_id=payload.project_id ) ip, ua = client_meta(request) await record_audit( session, action="delivery.run_started", organization_id=context.org_id, actor_user_id=context.user.id, target_type="pipeline_run", target_id=run.id, metadata={"project_id": run.project_id, "sha": run.sha, "trigger": run.trigger}, ip=ip, user_agent=ua, ) return _run_out(run) @router.post("/runs/{run_id}/cancel", response_model=PipelineRunOut) async def cancel_run( run_id: str, request: Request, context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), session: AsyncSession = Depends(get_session), ) -> PipelineRunOut: context.require(CAN_MANAGE_PROJECTS) run = await service.cancel_run(organization_id=context.org_id, run_id=run_id) ip, ua = client_meta(request) await record_audit( session, action="delivery.run_cancelled", organization_id=context.org_id, actor_user_id=context.user.id, target_type="pipeline_run", target_id=run.id, ip=ip, user_agent=ua, ) return _run_out(run) # --------------------------------------------------------------------------- # Releases & runtime # --------------------------------------------------------------------------- @router.get("/releases", response_model=list[ReleaseOut]) async def list_releases( project_id: str = Query(min_length=1), context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), ) -> list[ReleaseOut]: releases = await service.list_releases( organization_id=context.org_id, project_id=project_id ) return [_release_out(r) for r in releases] @router.get("/runtime", response_model=RuntimeOut) async def get_runtime( project_id: str = Query(min_length=1), context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), ) -> RuntimeOut: runtime = await service.runtime_status( organization_id=context.org_id, project_id=project_id ) return _runtime_out(runtime) @router.post("/releases/rollback", response_model=ReleaseOut) async def rollback_demo( payload: RollbackRequest, request: Request, context: OrgContext = Depends(get_org_context), service: DeliveryService = Depends(get_delivery_service), session: AsyncSession = Depends(get_session), ) -> ReleaseOut: """Point the demo host back at an earlier release (admin+, audited).""" context.require(CAN_MANAGE_PROJECTS) release = await service.rollback_demo( organization_id=context.org_id, project_id=payload.project_id, release_id=payload.release_id, ) ip, ua = client_meta(request) await record_audit( session, action="delivery.demo_rollback", organization_id=context.org_id, actor_user_id=context.user.id, target_type="release", target_id=release.id, metadata={"project_id": release.project_id, "sha": release.sha}, ip=ip, user_agent=ua, ) return _release_out(release)