Commit remaining workspace changes

This commit is contained in:
2026-03-13 11:45:06 +03:00
parent 36742cd20c
commit 03a90f58bd
8 changed files with 537 additions and 16 deletions

View File

@@ -36,6 +36,7 @@ from src.models.clean_release import (
ReleaseCandidateStatus,
)
from src.services.clean_release.approval_service import approve_candidate
from src.services.clean_release.artifact_catalog_loader import load_bootstrap_artifacts
from src.services.clean_release.compliance_execution_service import ComplianceExecutionService
from src.services.clean_release.enums import CandidateStatus
from src.services.clean_release.manifest_service import build_manifest_snapshot
@@ -270,6 +271,15 @@ class CleanReleaseTUI:
status=ReleaseCandidateStatus.DRAFT,
)
repository.save_candidate(candidate)
imported_artifacts = load_bootstrap_artifacts(
os.getenv("CLEAN_TUI_ARTIFACTS_JSON", "").strip(),
candidate.id,
)
for artifact in imported_artifacts:
repository.save_artifact(artifact)
if imported_artifacts:
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
registry_id = payload.get("registry_id", "REG-1")
entries = [

View File

@@ -0,0 +1,94 @@
# [DEF:backend.src.services.clean_release.artifact_catalog_loader:Module]
# @TIER: STANDARD
# @SEMANTICS: clean-release, artifacts, bootstrap, json, tui
# @PURPOSE: Load bootstrap artifact catalogs for clean release real-mode flows.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release.CandidateArtifact
# @INVARIANT: Artifact catalog must produce deterministic CandidateArtifact entries with required identity and checksum fields.
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List
from ...models.clean_release import CandidateArtifact
# [DEF:load_bootstrap_artifacts:Function]
# @PURPOSE: Parse artifact catalog JSON into CandidateArtifact models for TUI/bootstrap flows.
# @PRE: path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}.
# @POST: Returns non-mutated CandidateArtifact models with required fields populated.
def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArtifact]:
if not path or not path.strip():
return []
if not candidate_id or not candidate_id.strip():
raise ValueError("candidate_id must be non-empty for artifact catalog import")
catalog_path = Path(path)
payload = json.loads(catalog_path.read_text(encoding="utf-8"))
raw_artifacts = payload.get("artifacts") if isinstance(payload, dict) else payload
if not isinstance(raw_artifacts, list):
raise ValueError("artifact catalog must be a list or an object with 'artifacts' list")
artifacts: List[CandidateArtifact] = []
for index, raw_artifact in enumerate(raw_artifacts, start=1):
if not isinstance(raw_artifact, dict):
raise ValueError(f"artifact #{index} must be an object")
artifact_id = str(raw_artifact.get("id", "")).strip()
artifact_path = str(raw_artifact.get("path", "")).strip()
artifact_sha256 = str(raw_artifact.get("sha256", "")).strip()
artifact_size = raw_artifact.get("size")
if not artifact_id:
raise ValueError(f"artifact #{index} missing required field 'id'")
if not artifact_path:
raise ValueError(f"artifact #{index} missing required field 'path'")
if not artifact_sha256:
raise ValueError(f"artifact #{index} missing required field 'sha256'")
if not isinstance(artifact_size, int) or artifact_size < 0:
raise ValueError(f"artifact #{index} field 'size' must be non-negative integer")
category = str(raw_artifact.get("detected_category") or raw_artifact.get("category") or "").strip() or None
source_uri = str(raw_artifact.get("source_uri", "")).strip() or None
source_host = str(raw_artifact.get("source_host", "")).strip() or None
metadata_json = raw_artifact.get("metadata_json")
if metadata_json is None:
metadata_json = {
key: value
for key, value in raw_artifact.items()
if key
not in {
"id",
"path",
"sha256",
"size",
"category",
"detected_category",
"source_uri",
"source_host",
"metadata_json",
}
}
if not isinstance(metadata_json, dict):
raise ValueError(f"artifact #{index} field 'metadata_json' must be object")
artifacts.append(
CandidateArtifact(
id=artifact_id,
candidate_id=candidate_id,
path=artifact_path,
sha256=artifact_sha256,
size=artifact_size,
detected_category=category,
declared_category=category,
source_uri=source_uri,
source_host=source_host,
metadata_json=metadata_json,
)
)
return artifacts
# [/DEF:load_bootstrap_artifacts:Function]
# [/DEF:backend.src.services.clean_release.artifact_catalog_loader:Module]

View File

@@ -9,6 +9,7 @@
import os
import sys
import curses
import json
from unittest import mock
from unittest.mock import MagicMock, patch
@@ -152,4 +153,71 @@ def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock):
assert len(app.checks_progress) == 0
@patch("src.scripts.clean_release_tui.curses")
def test_tui_real_mode_bootstrap_imports_artifacts_catalog(
mock_curses_module,
mock_stdscr: MagicMock,
tmp_path,
):
"""
@TEST_CONTRACT: bootstrap.json + artifacts.json -> candidate PREPARED with imported artifacts
"""
mock_curses_module.KEY_F10 = curses.KEY_F10
mock_curses_module.color_pair.side_effect = lambda x: x
mock_curses_module.A_BOLD = 0
bootstrap_path = tmp_path / "bootstrap.json"
artifacts_path = tmp_path / "artifacts.json"
bootstrap_path.write_text(
json.dumps(
{
"candidate_id": "real-candidate-1",
"version": "1.0.0",
"source_snapshot_ref": "git:release/1",
"created_by": "operator",
"allowed_hosts": ["repo.intra.company.local"],
}
),
encoding="utf-8",
)
artifacts_path.write_text(
json.dumps(
{
"artifacts": [
{
"id": "artifact-1",
"path": "backend/dist/package.tar.gz",
"sha256": "deadbeef",
"size": 1024,
"category": "core",
"source_uri": "https://repo.intra.company.local/releases/package.tar.gz",
"source_host": "repo.intra.company.local",
}
]
}
),
encoding="utf-8",
)
with mock.patch.dict(
os.environ,
{
"CLEAN_TUI_MODE": "real",
"CLEAN_TUI_BOOTSTRAP_JSON": str(bootstrap_path),
"CLEAN_TUI_ARTIFACTS_JSON": str(artifacts_path),
},
clear=False,
):
app = CleanReleaseTUI(mock_stdscr)
candidate = app.repo.get_candidate("real-candidate-1")
artifacts = app.repo.get_artifacts_by_candidate("real-candidate-1")
assert candidate is not None
assert candidate.status == "PREPARED"
assert len(artifacts) == 1
assert artifacts[0].path == "backend/dist/package.tar.gz"
assert artifacts[0].detected_category == "core"
# [/DEF:backend.tests.scripts.test_clean_release_tui:Module]