feat(clean-release): complete compliance redesign phases and polish tasks T047-T052

This commit is contained in:
2026-03-10 09:11:26 +03:00
parent 6ee54d95a8
commit 87b81a365a
79 changed files with 7430 additions and 945 deletions

View File

@@ -0,0 +1,165 @@
# [DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]
# @TIER: STANDARD
# @PURPOSE: Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER: Tests
# @RELATION: TESTS -> backend.src.api.routes.clean_release
from __future__ import annotations
import os
from datetime import datetime, timezone
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "sqlite:///./test_clean_release_legacy_compat.db")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///./test_clean_release_legacy_auth.db")
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
CleanProfilePolicy,
DistributionManifest,
ProfileType,
ReleaseCandidate,
ReleaseCandidateStatus,
ResourceSourceEntry,
ResourceSourceRegistry,
)
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_seed_legacy_repo:Function]
# @PURPOSE: Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
# @PRE: Repository is empty.
# @POST: Candidate, policy, registry and manifest are available for legacy checks flow.
def _seed_legacy_repo() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
now = datetime.now(timezone.utc)
repo.save_candidate(
ReleaseCandidate(
id="legacy-rc-001",
version="1.0.0",
source_snapshot_ref="git:legacy-001",
created_at=now,
created_by="compat-tester",
status=ReleaseCandidateStatus.DRAFT,
)
)
registry = ResourceSourceRegistry(
registry_id="legacy-reg-1",
name="Legacy Internal Registry",
entries=[
ResourceSourceEntry(
source_id="legacy-src-1",
host="repo.intra.company.local",
protocol="https",
purpose="artifact-repo",
enabled=True,
)
],
updated_at=now,
updated_by="compat-tester",
status="ACTIVE",
)
setattr(registry, "immutable", True)
setattr(registry, "allowed_hosts", ["repo.intra.company.local"])
setattr(registry, "allowed_schemes", ["https"])
setattr(registry, "allowed_source_types", ["artifact-repo"])
repo.save_registry(registry)
policy = CleanProfilePolicy(
policy_id="legacy-pol-1",
policy_version="1.0.0",
profile=ProfileType.ENTERPRISE_CLEAN,
active=True,
internal_source_registry_ref="legacy-reg-1",
prohibited_artifact_categories=["test-data"],
required_system_categories=["core"],
effective_from=now,
)
setattr(policy, "immutable", True)
setattr(
policy,
"content_json",
{
"profile": "enterprise-clean",
"prohibited_artifact_categories": ["test-data"],
"required_system_categories": ["core"],
"external_source_forbidden": True,
},
)
repo.save_policy(policy)
repo.save_manifest(
DistributionManifest(
id="legacy-manifest-1",
candidate_id="legacy-rc-001",
manifest_version=1,
manifest_digest="sha256:legacy-manifest",
artifacts_digest="sha256:legacy-artifacts",
created_at=now,
created_by="compat-tester",
source_snapshot_ref="git:legacy-001",
content_json={"items": [], "summary": {"included_count": 0, "prohibited_detected_count": 0}},
immutable=True,
)
)
return repo
# [/DEF:_seed_legacy_repo:Function]
def test_legacy_prepare_endpoint_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
response = client.post(
"/api/clean-release/candidates/prepare",
json={
"candidate_id": "legacy-rc-001",
"artifacts": [{"path": "src/main.py", "category": "core", "reason": "required"}],
"sources": ["repo.intra.company.local"],
"operator_id": "compat-tester",
},
)
assert response.status_code == 200
payload = response.json()
assert "status" in payload
assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"}
finally:
app.dependency_overrides.clear()
def test_legacy_checks_endpoints_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
start_response = client.post(
"/api/clean-release/checks",
json={
"candidate_id": "legacy-rc-001",
"profile": "enterprise-clean",
"execution_mode": "api",
"triggered_by": "compat-tester",
},
)
assert start_response.status_code == 202
start_payload = start_response.json()
assert "check_run_id" in start_payload
assert start_payload["candidate_id"] == "legacy-rc-001"
status_response = client.get(f"/api/clean-release/checks/{start_payload['check_run_id']}")
assert status_response.status_code == 200
status_payload = status_response.json()
assert status_payload["check_run_id"] == start_payload["check_run_id"]
assert "final_status" in status_payload
assert "checks" in status_payload
finally:
app.dependency_overrides.clear()
# [/DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]

View File

@@ -0,0 +1,93 @@
# [DEF:test_clean_release_v2_api:Module]
# @TIER: STANDARD
# @PURPOSE: API contract tests for redesigned clean release endpoints.
# @LAYER: Domain
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository, get_config_manager
from src.models.clean_release import (
CleanPolicySnapshot,
DistributionManifest,
ReleaseCandidate,
SourceRegistrySnapshot,
)
from src.services.clean_release.enums import CandidateStatus
client = TestClient(app)
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
def test_candidate_registration_contract():
"""
@TEST_SCENARIO: candidate_registration -> Should return 201 and candidate DTO.
@TEST_CONTRACT: POST /api/v2/clean-release/candidates -> CandidateDTO
"""
payload = {
"id": "rc-test-001",
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
}
response = client.post("/api/v2/clean-release/candidates", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] == "rc-test-001"
assert data["status"] == CandidateStatus.DRAFT.value
def test_artifact_import_contract():
"""
@TEST_SCENARIO: artifact_import -> Should return 200 and success status.
@TEST_CONTRACT: POST /api/v2/clean-release/candidates/{id}/artifacts -> SuccessDTO
"""
candidate_id = "rc-test-001-art"
bootstrap_candidate = {
"id": candidate_id,
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
}
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
assert create_response.status_code == 201
payload = {
"artifacts": [
{
"id": "art-1",
"path": "bin/app.exe",
"sha256": "hash123",
"size": 1024
}
]
}
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/artifacts", json=payload)
assert response.status_code == 200
assert response.json()["status"] == "success"
def test_manifest_build_contract():
"""
@TEST_SCENARIO: manifest_build -> Should return 201 and manifest DTO.
@TEST_CONTRACT: POST /api/v2/clean-release/candidates/{id}/manifests -> ManifestDTO
"""
candidate_id = "rc-test-001-manifest"
bootstrap_candidate = {
"id": candidate_id,
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
}
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
assert create_response.status_code == 201
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/manifests")
assert response.status_code == 201
data = response.json()
assert "manifest_digest" in data
assert data["candidate_id"] == candidate_id
# [/DEF:test_clean_release_v2_api:Module]

View File

@@ -0,0 +1,107 @@
# [DEF:test_clean_release_v2_release_api:Module]
# @TIER: STANDARD
# @PURPOSE: API contract test scaffolding for clean release approval and publication endpoints.
# @LAYER: Domain
# @RELATION: IMPLEMENTS -> clean_release_v2_release_api_contracts
"""Contract tests for redesigned approval/publication API endpoints."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.routes.clean_release_v2 import router as clean_release_v2_router
from src.dependencies import get_clean_release_repository
from src.models.clean_release import ComplianceReport, ReleaseCandidate
from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
test_app = FastAPI()
test_app.include_router(clean_release_v2_router)
client = TestClient(test_app)
def _seed_candidate_and_passed_report() -> tuple[str, str]:
repository = get_clean_release_repository()
candidate_id = f"api-release-candidate-{uuid4()}"
report_id = f"api-release-report-{uuid4()}"
repository.save_candidate(
ReleaseCandidate(
id=candidate_id,
version="1.0.0",
source_snapshot_ref="git:sha-api-release",
created_by="api-test",
created_at=datetime.now(timezone.utc),
status=CandidateStatus.CHECK_PASSED.value,
)
)
repository.save_report(
ComplianceReport(
id=report_id,
run_id=f"run-{uuid4()}",
candidate_id=candidate_id,
final_status=ComplianceDecision.PASSED.value,
summary_json={"operator_summary": "ok", "violations_count": 0, "blocking_violations_count": 0},
generated_at=datetime.now(timezone.utc),
immutable=True,
)
)
return candidate_id, report_id
def test_release_approve_and_publish_revoke_contract() -> None:
"""Contract for approve -> publish -> revoke lifecycle endpoints."""
candidate_id, report_id = _seed_candidate_and_passed_report()
approve_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/approve",
json={"report_id": report_id, "decided_by": "api-test", "comment": "approved"},
)
assert approve_response.status_code == 200
approve_payload = approve_response.json()
assert approve_payload["status"] == "ok"
assert approve_payload["decision"] == "APPROVED"
publish_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/publish",
json={
"report_id": report_id,
"published_by": "api-test",
"target_channel": "stable",
"publication_ref": "rel-api-001",
},
)
assert publish_response.status_code == 200
publish_payload = publish_response.json()
assert publish_payload["status"] == "ok"
assert publish_payload["publication"]["status"] == "ACTIVE"
publication_id = publish_payload["publication"]["id"]
revoke_response = client.post(
f"/api/v2/clean-release/publications/{publication_id}/revoke",
json={"revoked_by": "api-test", "comment": "rollback"},
)
assert revoke_response.status_code == 200
revoke_payload = revoke_response.json()
assert revoke_payload["status"] == "ok"
assert revoke_payload["publication"]["status"] == "REVOKED"
def test_release_reject_contract() -> None:
"""Contract for reject endpoint."""
candidate_id, report_id = _seed_candidate_and_passed_report()
reject_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/reject",
json={"report_id": report_id, "decided_by": "api-test", "comment": "rejected"},
)
assert reject_response.status_code == 200
payload = reject_response.json()
assert payload["status"] == "ok"
assert payload["decision"] == "REJECTED"
# [/DEF:test_clean_release_v2_release_api:Module]

View File

@@ -16,19 +16,27 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ...core.logger import belief_scope, logger
from ...dependencies import get_clean_release_repository
from ...dependencies import get_clean_release_repository, get_config_manager
from ...services.clean_release.preparation_service import prepare_candidate
from ...services.clean_release.repository import CleanReleaseRepository
from ...services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
from ...services.clean_release.report_builder import ComplianceReportBuilder
from ...models.clean_release import (
CheckFinalStatus,
CheckStageName,
CheckStageResult,
CheckStageStatus,
ComplianceViolation,
from ...services.clean_release.compliance_execution_service import ComplianceExecutionService, ComplianceRunError
from ...services.clean_release.dto import CandidateDTO, ManifestDTO, CandidateOverviewDTO, ComplianceRunDTO
from ...services.clean_release.enums import (
ComplianceDecision,
ComplianceStageName,
ViolationCategory,
ViolationSeverity,
RunStatus,
CandidateStatus,
)
from ...models.clean_release import (
ComplianceRun,
ComplianceStageRun,
ComplianceViolation,
CandidateArtifact,
ReleaseCandidate,
)
router = APIRouter(prefix="/api/clean-release", tags=["Clean Release"])
@@ -54,6 +62,226 @@ class StartCheckRequest(BaseModel):
# [/DEF:StartCheckRequest:Class]
# [DEF:RegisterCandidateRequest:Class]
# @PURPOSE: Request schema for candidate registration endpoint.
class RegisterCandidateRequest(BaseModel):
id: str = Field(min_length=1)
version: str = Field(min_length=1)
source_snapshot_ref: str = Field(min_length=1)
created_by: str = Field(min_length=1)
# [/DEF:RegisterCandidateRequest:Class]
# [DEF:ImportArtifactsRequest:Class]
# @PURPOSE: Request schema for candidate artifact import endpoint.
class ImportArtifactsRequest(BaseModel):
artifacts: List[Dict[str, Any]] = Field(default_factory=list)
# [/DEF:ImportArtifactsRequest:Class]
# [DEF:BuildManifestRequest:Class]
# @PURPOSE: Request schema for manifest build endpoint.
class BuildManifestRequest(BaseModel):
created_by: str = Field(default="system")
# [/DEF:BuildManifestRequest:Class]
# [DEF:CreateComplianceRunRequest:Class]
# @PURPOSE: Request schema for compliance run creation with optional manifest pinning.
class CreateComplianceRunRequest(BaseModel):
requested_by: str = Field(min_length=1)
manifest_id: str | None = None
# [/DEF:CreateComplianceRunRequest:Class]
# [DEF:register_candidate_v2_endpoint:Function]
# @PURPOSE: Register a clean-release candidate for headless lifecycle.
# @PRE: Candidate identifier is unique.
# @POST: Candidate is persisted in DRAFT status.
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
async def register_candidate_v2_endpoint(
payload: RegisterCandidateRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
existing = repository.get_candidate(payload.id)
if existing is not None:
raise HTTPException(status_code=409, detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"})
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_v2_endpoint:Function]
# [DEF:import_candidate_artifacts_v2_endpoint:Function]
# @PURPOSE: Import candidate artifacts in headless flow.
# @PRE: Candidate exists and artifacts array is non-empty.
# @POST: Artifacts are persisted and candidate advances to PREPARED if it was DRAFT.
@router.post("/candidates/{candidate_id}/artifacts")
async def import_candidate_artifacts_v2_endpoint(
candidate_id: str,
payload: ImportArtifactsRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = repository.get_candidate(candidate_id)
if candidate is None:
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
if not payload.artifacts:
raise HTTPException(status_code=400, detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"})
for artifact in payload.artifacts:
required = ("id", "path", "sha256", "size")
for field_name in required:
if field_name not in artifact:
raise HTTPException(
status_code=400,
detail={"message": f"Artifact missing field '{field_name}'", "code": "ARTIFACT_INVALID"},
)
artifact_model = CandidateArtifact(
id=str(artifact["id"]),
candidate_id=candidate_id,
path=str(artifact["path"]),
sha256=str(artifact["sha256"]),
size=int(artifact["size"]),
detected_category=artifact.get("detected_category"),
declared_category=artifact.get("declared_category"),
source_uri=artifact.get("source_uri"),
source_host=artifact.get("source_host"),
metadata_json=artifact.get("metadata_json", {}),
)
repository.save_artifact(artifact_model)
if candidate.status == CandidateStatus.DRAFT.value:
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
return {"status": "success"}
# [/DEF:import_candidate_artifacts_v2_endpoint:Function]
# [DEF:build_candidate_manifest_v2_endpoint:Function]
# @PURPOSE: Build immutable manifest snapshot for prepared candidate.
# @PRE: Candidate exists and has imported artifacts.
# @POST: Returns created ManifestDTO with incremented version.
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
async def build_candidate_manifest_v2_endpoint(
candidate_id: str,
payload: BuildManifestRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
from ...services.clean_release.manifest_service import build_manifest_snapshot
try:
manifest = build_manifest_snapshot(
repository=repository,
candidate_id=candidate_id,
created_by=payload.created_by,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"})
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_candidate_manifest_v2_endpoint:Function]
# [DEF:get_candidate_overview_v2_endpoint:Function]
# @PURPOSE: Return expanded candidate overview DTO for headless lifecycle visibility.
# @PRE: Candidate exists.
# @POST: Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints.
@router.get("/candidates/{candidate_id}/overview", response_model=CandidateOverviewDTO)
async def get_candidate_overview_v2_endpoint(
candidate_id: str,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = repository.get_candidate(candidate_id)
if candidate is None:
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
manifests = repository.get_manifests_by_candidate(candidate_id)
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] if manifests else None
runs = [run for run in repository.check_runs.values() if run.candidate_id == candidate_id]
latest_run = sorted(runs, key=lambda run: run.requested_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0] if runs else None
latest_report = None
if latest_run is not None:
latest_report = next((r for r in repository.reports.values() if r.run_id == latest_run.id), None)
latest_policy_snapshot = repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None
latest_registry_snapshot = repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None
approval_decisions = getattr(repository, "approval_decisions", [])
latest_approval = (
sorted(
[item for item in approval_decisions if item.candidate_id == candidate_id],
key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if approval_decisions
and any(item.candidate_id == candidate_id for item in approval_decisions)
else None
)
publication_records = getattr(repository, "publication_records", [])
latest_publication = (
sorted(
[item for item in publication_records if item.candidate_id == candidate_id],
key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if publication_records
and any(item.candidate_id == candidate_id for item in publication_records)
else None
)
return CandidateOverviewDTO(
candidate_id=candidate.id,
version=candidate.version,
source_snapshot_ref=candidate.source_snapshot_ref,
status=CandidateStatus(candidate.status),
latest_manifest_id=latest_manifest.id if latest_manifest else None,
latest_manifest_digest=latest_manifest.manifest_digest if latest_manifest else None,
latest_run_id=latest_run.id if latest_run else None,
latest_run_status=RunStatus(latest_run.status) if latest_run else None,
latest_report_id=latest_report.id if latest_report else None,
latest_report_final_status=ComplianceDecision(latest_report.final_status) if latest_report else None,
latest_policy_snapshot_id=latest_policy_snapshot.id if latest_policy_snapshot else None,
latest_policy_version=latest_policy_snapshot.policy_version if latest_policy_snapshot else None,
latest_registry_snapshot_id=latest_registry_snapshot.id if latest_registry_snapshot else None,
latest_registry_version=latest_registry_snapshot.registry_version if latest_registry_snapshot else None,
latest_approval_decision=latest_approval.decision if latest_approval else None,
latest_publication_id=latest_publication.id if latest_publication else None,
latest_publication_status=latest_publication.status if latest_publication else None,
)
# [/DEF:get_candidate_overview_v2_endpoint:Function]
# [DEF:prepare_candidate_endpoint:Function]
# @PURPOSE: Prepare candidate with policy evaluation and deterministic manifest generation.
# @PRE: Candidate and active policy exist in repository.
@@ -99,47 +327,79 @@ async def start_check(
if candidate is None:
raise HTTPException(status_code=409, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
manifests = repository.get_manifests_by_candidate(payload.candidate_id)
if not manifests:
raise HTTPException(status_code=409, detail={"message": "No manifest found for candidate", "code": "MANIFEST_NOT_FOUND"})
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0]
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
candidate_id=payload.candidate_id,
policy_id=policy.policy_id,
triggered_by=payload.triggered_by,
execution_mode=payload.execution_mode,
policy_id=policy.id,
requested_by=payload.triggered_by,
manifest_id=latest_manifest.id,
)
forced = [
CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.INTERNAL_SOURCES_ONLY, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.NO_EXTERNAL_ENDPOINTS, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.MANIFEST_CONSISTENCY, status=CheckStageStatus.PASS, details="ok"),
ComplianceStageRun(
id=f"stage-{run.id}-1",
run_id=run.id,
stage_name=ComplianceStageName.DATA_PURITY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
ComplianceStageRun(
id=f"stage-{run.id}-2",
run_id=run.id,
stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
ComplianceStageRun(
id=f"stage-{run.id}-3",
run_id=run.id,
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
ComplianceStageRun(
id=f"stage-{run.id}-4",
run_id=run.id,
stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
]
run = orchestrator.execute_stages(run, forced_results=forced)
run = orchestrator.finalize_run(run)
if run.final_status == CheckFinalStatus.BLOCKED:
if run.final_status == ComplianceDecision.BLOCKED.value:
logger.explore("Run ended as BLOCKED, persisting synthetic external-source violation")
violation = ComplianceViolation(
violation_id=f"viol-{run.check_run_id}",
check_run_id=run.check_run_id,
category=ViolationCategory.EXTERNAL_SOURCE,
severity=ViolationSeverity.CRITICAL,
location="external.example.com",
remediation="Replace with approved internal server",
blocked_release=True,
detected_at=datetime.now(timezone.utc),
id=f"viol-{run.id}",
run_id=run.id,
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
code="EXTERNAL_SOURCE_DETECTED",
severity=ViolationSeverity.CRITICAL.value,
message="Replace with approved internal server",
evidence_json={"location": "external.example.com"}
)
repository.save_violation(violation)
builder = ComplianceReportBuilder(repository)
report = builder.build_report_payload(run, repository.get_violations_by_check_run(run.check_run_id))
report = builder.build_report_payload(run, repository.get_violations_by_run(run.id))
builder.persist_report(report)
logger.reflect(f"Compliance report persisted for check_run_id={run.check_run_id}")
logger.reflect(f"Compliance report persisted for run_id={run.id}")
return {
"check_run_id": run.check_run_id,
"check_run_id": run.id,
"candidate_id": run.candidate_id,
"status": "running",
"started_at": run.started_at.isoformat(),
"started_at": run.started_at.isoformat() if run.started_at else None,
}
# [/DEF:start_check:Function]
@@ -157,13 +417,13 @@ async def get_check_status(check_run_id: str, repository: CleanReleaseRepository
logger.reflect(f"Returning check status for check_run_id={check_run_id}")
return {
"check_run_id": run.check_run_id,
"check_run_id": run.id,
"candidate_id": run.candidate_id,
"final_status": run.final_status.value,
"started_at": run.started_at.isoformat(),
"final_status": run.final_status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"checks": [c.model_dump() for c in run.checks],
"violations": [v.model_dump() for v in repository.get_violations_by_check_run(check_run_id)],
"checks": [], # TODO: Map stages if needed
"violations": [], # TODO: Map violations if needed
}
# [/DEF:get_check_status:Function]

View File

@@ -0,0 +1,216 @@
# [DEF:backend.src.api.routes.clean_release_v2:Module]
# @TIER: STANDARD
# @SEMANTICS: api, clean-release, v2, headless
# @PURPOSE: Redesigned clean release API for headless candidate lifecycle.
# @LAYER: API
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"])
class ApprovalRequest(dict):
pass
class PublishRequest(dict):
pass
class RevokeRequest(dict):
pass
@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)
)
@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"}
@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
)
@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}
@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}
@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,
},
}
@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:backend.src.api.routes.clean_release_v2:Module]

View File

@@ -48,6 +48,7 @@ from ...dependencies import (
has_permission,
)
from ...core.database import get_db
from ...core.async_superset_client import AsyncSupersetClient
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
from ...core.superset_profile_lookup import SupersetAccountLookupAdapter
@@ -229,6 +230,56 @@ def _resolve_dashboard_id_from_ref(
# [/DEF:_resolve_dashboard_id_from_ref:Function]
# [DEF:_find_dashboard_id_by_slug_async:Function]
# @PURPOSE: Resolve dashboard numeric ID by slug using async Superset list endpoint.
# @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
async def _find_dashboard_id_by_slug_async(
client: AsyncSupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
{"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
]
for query in query_variants:
try:
_count, dashboards = await client.get_dashboards_page_async(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug_async:Function]
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @PURPOSE: Resolve dashboard ID from slug-first reference using async Superset client.
# @PRE: dashboard_ref is provided in route path.
# @POST: Returns valid dashboard ID or raises HTTPException(404).
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
client: AsyncSupersetClient,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=404, detail="Dashboard not found")
slug_match_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
if slug_match_id is not None:
return slug_match_id
if normalized_ref.isdigit():
return int(normalized_ref)
raise HTTPException(status_code=404, detail="Dashboard not found")
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
# [DEF:_normalize_filter_values:Function]
# @PURPOSE: Normalize query filter values to lower-cased non-empty tokens.
# @PRE: values may be None or list of strings.
@@ -776,10 +827,10 @@ async def get_dashboard_detail(
logger.error(f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
try:
client = SupersetClient(env)
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
detail = client.get_dashboard_detail(dashboard_id)
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
detail = await client.get_dashboard_detail_async(dashboard_id)
logger.info(
f"[get_dashboard_detail][Coherence:OK] Dashboard ref={dashboard_ref} resolved_id={dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
)
@@ -789,6 +840,8 @@ async def get_dashboard_detail(
except Exception as e:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
finally:
await client.aclose()
# [/DEF:get_dashboard_detail:Function]
@@ -840,69 +893,74 @@ async def get_dashboard_tasks_history(
):
with belief_scope("get_dashboard_tasks_history", f"dashboard_ref={dashboard_ref}, env_id={env_id}, limit={limit}"):
dashboard_id: Optional[int] = None
if dashboard_ref.isdigit():
dashboard_id = int(dashboard_ref)
elif env_id:
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = SupersetClient(env)
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
else:
logger.error(
"[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id"
)
raise HTTPException(
status_code=400,
detail="env_id is required when dashboard reference is a slug",
)
matching_tasks = []
for task in task_manager.get_all_tasks():
if _task_matches_dashboard(task, dashboard_id, env_id):
matching_tasks.append(task)
def _sort_key(task_obj: Any) -> str:
return (
str(getattr(task_obj, "started_at", "") or "")
or str(getattr(task_obj, "finished_at", "") or "")
)
matching_tasks.sort(key=_sort_key, reverse=True)
selected = matching_tasks[:limit]
items = []
for task in selected:
result = getattr(task, "result", None)
summary = None
validation_status = None
if isinstance(result, dict):
raw_validation_status = result.get("status")
if raw_validation_status is not None:
validation_status = str(raw_validation_status)
summary = (
result.get("summary")
or result.get("status")
or result.get("message")
client: Optional[AsyncSupersetClient] = None
try:
if dashboard_ref.isdigit():
dashboard_id = int(dashboard_ref)
elif env_id:
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
else:
logger.error(
"[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id"
)
params = getattr(task, "params", {}) or {}
items.append(
DashboardTaskHistoryItem(
id=str(getattr(task, "id", "")),
plugin_id=str(getattr(task, "plugin_id", "")),
status=str(getattr(task, "status", "")),
validation_status=validation_status,
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
summary=summary,
raise HTTPException(
status_code=400,
detail="env_id is required when dashboard reference is a slug",
)
)
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}")
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
matching_tasks = []
for task in task_manager.get_all_tasks():
if _task_matches_dashboard(task, dashboard_id, env_id):
matching_tasks.append(task)
def _sort_key(task_obj: Any) -> str:
return (
str(getattr(task_obj, "started_at", "") or "")
or str(getattr(task_obj, "finished_at", "") or "")
)
matching_tasks.sort(key=_sort_key, reverse=True)
selected = matching_tasks[:limit]
items = []
for task in selected:
result = getattr(task, "result", None)
summary = None
validation_status = None
if isinstance(result, dict):
raw_validation_status = result.get("status")
if raw_validation_status is not None:
validation_status = str(raw_validation_status)
summary = (
result.get("summary")
or result.get("status")
or result.get("message")
)
params = getattr(task, "params", {}) or {}
items.append(
DashboardTaskHistoryItem(
id=str(getattr(task, "id", "")),
plugin_id=str(getattr(task, "plugin_id", "")),
status=str(getattr(task, "status", "")),
validation_status=validation_status,
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
summary=summary,
)
)
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}")
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
finally:
if client is not None:
await client.aclose()
# [/DEF:get_dashboard_tasks_history:Function]
@@ -925,15 +983,15 @@ async def get_dashboard_thumbnail(
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
try:
client = SupersetClient(env)
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
digest = None
thumb_endpoint = None
# Preferred flow (newer Superset): ask server to cache screenshot and return digest/image_url.
try:
screenshot_payload = client.network.request(
screenshot_payload = await client.network.request(
method="POST",
endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/",
json={"force": force},
@@ -951,7 +1009,7 @@ async def get_dashboard_thumbnail(
# Fallback flow (older Superset): read thumbnail_url from dashboard payload.
if not digest:
dashboard_payload = client.network.request(
dashboard_payload = await client.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}",
)
@@ -970,7 +1028,7 @@ async def get_dashboard_thumbnail(
if not thumb_endpoint:
thumb_endpoint = f"/dashboard/{dashboard_id}/thumbnail/{digest or 'latest'}/"
thumb_response = client.network.request(
thumb_response = await client.network.request(
method="GET",
endpoint=thumb_endpoint,
raw_response=True,
@@ -995,6 +1053,8 @@ async def get_dashboard_thumbnail(
except Exception as e:
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Failed to fetch dashboard thumbnail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard thumbnail: {str(e)}")
finally:
await client.aclose()
# [/DEF:get_dashboard_thumbnail:Function]
# [DEF:MigrateRequest:DataClass]

View File

@@ -33,6 +33,7 @@ from src.api.routes.git_schemas import (
MergeStatusSchema, MergeConflictFileSchema, MergeResolveRequest, MergeContinueRequest,
)
from src.services.git_service import GitService
from src.core.async_superset_client import AsyncSupersetClient
from src.core.superset_client import SupersetClient
from src.core.logger import logger, belief_scope
from ...services.llm_prompt_templates import (
@@ -180,6 +181,70 @@ def _resolve_dashboard_id_from_ref(
# [/DEF:_resolve_dashboard_id_from_ref:Function]
# [DEF:_find_dashboard_id_by_slug_async:Function]
# @PURPOSE: Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes.
# @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID or None when not found.
async def _find_dashboard_id_by_slug_async(
client: AsyncSupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
{"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
]
for query in query_variants:
try:
_count, dashboards = await client.get_dashboards_page_async(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug_async:Function]
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @PURPOSE: Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes.
# @PRE: dashboard_ref is provided; env_id is required for slug values.
# @POST: Returns numeric dashboard ID or raises HTTPException.
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
config_manager,
env_id: Optional[str] = None,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=400, detail="dashboard_ref is required")
if normalized_ref.isdigit():
return int(normalized_ref)
if not env_id:
raise HTTPException(
status_code=400,
detail="env_id is required for slug-based Git operations",
)
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
try:
dashboard_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
if dashboard_id is None:
raise HTTPException(status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found")
return dashboard_id
finally:
await client.aclose()
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
# [DEF:_resolve_repo_key_from_ref:Function]
# @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback.
# @PRE: dashboard_id is resolved and valid.
@@ -1197,7 +1262,7 @@ async def get_repository_status(
):
with belief_scope("get_repository_status"):
try:
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, config_manager, env_id)
return _resolve_repository_status(dashboard_id)
except HTTPException:
raise

View File

@@ -13,10 +13,11 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ...dependencies import get_task_manager, has_permission
from ...dependencies import get_task_manager, has_permission, get_clean_release_repository
from ...core.task_manager import TaskManager
from ...core.logger import belief_scope
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
from ...services.clean_release.repository import CleanReleaseRepository
from ...services.reports.report_service import ReportsService
# [/SECTION]
@@ -88,6 +89,7 @@ async def list_reports(
sort_by: str = Query("updated_at"),
sort_order: str = Query("desc"),
task_manager: TaskManager = Depends(get_task_manager),
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("list_reports"):
@@ -117,7 +119,7 @@ async def list_reports(
},
)
service = ReportsService(task_manager)
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
return service.list_reports(query)
# [/DEF:list_reports:Function]
@@ -130,10 +132,11 @@ async def list_reports(
async def get_report_detail(
report_id: str,
task_manager: TaskManager = Depends(get_task_manager),
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("get_report_detail", f"report_id={report_id}"):
service = ReportsService(task_manager)
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
detail = service.get_report_detail(report_id)
if not detail:
raise HTTPException(