Commit remaining workspace changes
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user