fix(release): fail closed on dashboard parse errors
This commit is contained in:
@@ -49,6 +49,12 @@ tag. `source_preflight_ready` means that the plan-visible source gates pass; the
|
|||||||
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
||||||
immutable-tag checks.
|
immutable-tag checks.
|
||||||
|
|
||||||
|
Dashboard collection is also fail closed. Unreadable Core version metadata or a
|
||||||
|
malformed module contract is returned as a bounded `collection_errors` entry
|
||||||
|
with a remediation, marks the dashboard blocked, and becomes a structured
|
||||||
|
blocker in both full and selective release plans. The console does not silently
|
||||||
|
omit a contract or turn these source errors into an HTTP 500.
|
||||||
|
|
||||||
The release-control area above the repository table is read-only and is meant
|
The release-control area above the repository table is read-only and is meant
|
||||||
to become the central release cockpit. It shows:
|
to become the central release cockpit. It shows:
|
||||||
|
|
||||||
|
|||||||
177
tests/test_release_dashboard_diagnostics.py
Normal file
177
tests/test_release_dashboard_diagnostics.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseDashboardDiagnosticsTests(unittest.TestCase):
|
||||||
|
def test_malformed_core_version_is_bounded_and_blocks_api_plans(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
core = workspace / "govoplan-core"
|
||||||
|
core.mkdir()
|
||||||
|
(core / "pyproject.toml").write_text("[project\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with TestClient(create_app(workspace_root=workspace)) as client:
|
||||||
|
dashboard_response = client.get(
|
||||||
|
"/api/dashboard", params={"public_catalog": "false"}
|
||||||
|
)
|
||||||
|
selective_response = client.get(
|
||||||
|
"/api/selective-plan",
|
||||||
|
params={
|
||||||
|
"repos": "govoplan-core",
|
||||||
|
"repo_versions": "govoplan-core:1.2.4",
|
||||||
|
"public_catalog": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
full_plan_response = client.get(
|
||||||
|
"/api/plan", params={"public_catalog": "false"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, dashboard_response.status_code)
|
||||||
|
dashboard = dashboard_response.json()
|
||||||
|
self.assertEqual("blocked", dashboard["summary"]["status"])
|
||||||
|
error = next(
|
||||||
|
item
|
||||||
|
for item in dashboard["collection_errors"]
|
||||||
|
if item["code"] == "core_version_metadata_unreadable"
|
||||||
|
)
|
||||||
|
self.assertEqual("govoplan-core/pyproject.toml", error["source"])
|
||||||
|
self.assertIn("TOMLDecodeError", error["message"])
|
||||||
|
self.assertIn("Repair govoplan-core/pyproject.toml", error["remediation"])
|
||||||
|
self.assertLess(len(error["message"]), 200)
|
||||||
|
self.assertNotIn(str(workspace), error["message"])
|
||||||
|
|
||||||
|
self.assertEqual(200, selective_response.status_code)
|
||||||
|
selective_plan = selective_response.json()
|
||||||
|
self.assertEqual("blocked", selective_plan["status"])
|
||||||
|
self.assertFalse(selective_plan["source_preflight_ready"])
|
||||||
|
finding = next(
|
||||||
|
item
|
||||||
|
for item in selective_plan["gate_findings"]
|
||||||
|
if item["code"] == "core_version_metadata_unreadable"
|
||||||
|
)
|
||||||
|
self.assertEqual(error["remediation"], finding["remediation"])
|
||||||
|
self.assertEqual(
|
||||||
|
"resolve_release_gate", selective_plan["recommended_action"]["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, full_plan_response.status_code)
|
||||||
|
full_plan = full_plan_response.json()
|
||||||
|
self.assertEqual("blocked", full_plan["status"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
action["id"].startswith("dashboard:core_version_metadata_unreadable:")
|
||||||
|
for action in full_plan["actions"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_manifest_is_not_silently_omitted_from_selected_plan(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
core = workspace / "govoplan-core"
|
||||||
|
core.mkdir()
|
||||||
|
(core / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-core"\nversion = "0.1.13"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
files = workspace / "govoplan-files"
|
||||||
|
manifest = files / "src" / "govoplan_files" / "backend" / "manifest.py"
|
||||||
|
manifest.parent.mkdir(parents=True)
|
||||||
|
(files / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manifest.write_text("def broken(:\n", encoding="utf-8")
|
||||||
|
initialize_repository(files)
|
||||||
|
|
||||||
|
with TestClient(create_app(workspace_root=workspace)) as client:
|
||||||
|
dashboard_response = client.get(
|
||||||
|
"/api/dashboard", params={"public_catalog": "false"}
|
||||||
|
)
|
||||||
|
selective_response = client.get(
|
||||||
|
"/api/selective-plan",
|
||||||
|
params={
|
||||||
|
"repos": "govoplan-files",
|
||||||
|
"repo_versions": "govoplan-files:1.2.4",
|
||||||
|
"public_catalog": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, dashboard_response.status_code)
|
||||||
|
dashboard = dashboard_response.json()
|
||||||
|
error = next(
|
||||||
|
item
|
||||||
|
for item in dashboard["collection_errors"]
|
||||||
|
if item["code"] == "module_contract_unreadable"
|
||||||
|
)
|
||||||
|
self.assertEqual("govoplan-files", error["repo"])
|
||||||
|
self.assertEqual(
|
||||||
|
"govoplan-files/src/govoplan_files/backend/manifest.py",
|
||||||
|
error["source"],
|
||||||
|
)
|
||||||
|
self.assertIn("SyntaxError", error["message"])
|
||||||
|
self.assertIn("Repair the manifest", error["remediation"])
|
||||||
|
|
||||||
|
self.assertEqual(200, selective_response.status_code)
|
||||||
|
plan = selective_response.json()
|
||||||
|
self.assertEqual("blocked", plan["status"])
|
||||||
|
self.assertFalse(plan["source_preflight_ready"])
|
||||||
|
self.assertIn(
|
||||||
|
"module_contract_unreadable",
|
||||||
|
{finding["code"] for finding in plan["gate_findings"]},
|
||||||
|
)
|
||||||
|
self.assertEqual("resolve_release_gate", plan["recommended_action"]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_repository(path: Path) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "--initial-branch=main", str(path)],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(path), "add", "."],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(path),
|
||||||
|
"-c",
|
||||||
|
"user.name=Release Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=release@example.invalid",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"Initial fixture",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .model import (
|
from .model import (
|
||||||
CompatibilityIssue,
|
CompatibilityIssue,
|
||||||
|
DashboardCollectionError,
|
||||||
InterfaceProviderSnapshot,
|
InterfaceProviderSnapshot,
|
||||||
InterfaceRequirementSnapshot,
|
InterfaceRequirementSnapshot,
|
||||||
ModuleContractSnapshot,
|
ModuleContractSnapshot,
|
||||||
@@ -33,6 +34,43 @@ def collect_contracts(repositories: tuple[RepositorySnapshot, ...]) -> tuple[Mod
|
|||||||
return tuple(contracts)
|
return tuple(contracts)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_contracts_with_diagnostics(
|
||||||
|
repositories: tuple[RepositorySnapshot, ...],
|
||||||
|
) -> tuple[tuple[ModuleContractSnapshot, ...], tuple[DashboardCollectionError, ...]]:
|
||||||
|
"""Collect readable contracts and retain bounded parse diagnostics for the UI."""
|
||||||
|
|
||||||
|
contracts: list[ModuleContractSnapshot] = []
|
||||||
|
diagnostics: list[DashboardCollectionError] = []
|
||||||
|
for repo in repositories:
|
||||||
|
if not repo.exists or not repo.is_git:
|
||||||
|
continue
|
||||||
|
root = Path(repo.absolute_path)
|
||||||
|
for manifest in sorted(root.glob("src/**/backend/manifest.py")):
|
||||||
|
try:
|
||||||
|
contract = parse_manifest_contract(manifest, repo_name=repo.spec.name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - fail-closed UI diagnostic.
|
||||||
|
relative_source = manifest.relative_to(root).as_posix()
|
||||||
|
diagnostics.append(
|
||||||
|
DashboardCollectionError(
|
||||||
|
code="module_contract_unreadable",
|
||||||
|
message=(
|
||||||
|
"Module contract metadata could not be read or parsed "
|
||||||
|
f"({type(exc).__name__})."
|
||||||
|
),
|
||||||
|
remediation=(
|
||||||
|
"Repair the manifest's Python syntax or read permissions, "
|
||||||
|
"then refresh the dashboard before planning a release."
|
||||||
|
),
|
||||||
|
repo=repo.spec.name,
|
||||||
|
source=f"{repo.spec.name}/{relative_source}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if contract is not None:
|
||||||
|
contracts.append(contract)
|
||||||
|
return tuple(contracts), tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]:
|
def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]:
|
||||||
"""Validate the statically collected module interface graph.
|
"""Validate the statically collected module interface graph.
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .catalog import collect_catalog_snapshot
|
from .catalog import collect_catalog_snapshot
|
||||||
from .contracts import collect_contracts
|
from .contracts import collect_contracts_with_diagnostics
|
||||||
from .git_state import collect_repository_snapshot, read_pyproject_version
|
from .git_state import collect_repository_snapshot, read_pyproject_version
|
||||||
from .migrations import collect_migration_snapshots
|
from .migrations import collect_migration_snapshots
|
||||||
from .model import DashboardSummary, ReleaseDashboard, RepositorySnapshot
|
from .model import DashboardCollectionError, DashboardSummary, ReleaseDashboard, RepositorySnapshot
|
||||||
from .workspace import META_ROOT, core_root, load_repository_specs, resolve_workspace_root
|
from .workspace import META_ROOT, core_root, load_repository_specs, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
@@ -26,10 +26,25 @@ def build_dashboard(
|
|||||||
channel: str = "stable",
|
channel: str = "stable",
|
||||||
) -> ReleaseDashboard:
|
) -> ReleaseDashboard:
|
||||||
resolved_workspace = resolve_workspace_root(workspace_root)
|
resolved_workspace = resolve_workspace_root(workspace_root)
|
||||||
|
collection_errors: list[DashboardCollectionError] = []
|
||||||
if check_public_catalog is None:
|
if check_public_catalog is None:
|
||||||
check_public_catalog = online
|
check_public_catalog = online
|
||||||
if target_version is None:
|
if target_version is None:
|
||||||
|
try:
|
||||||
target_version = read_pyproject_version(core_root(resolved_workspace))
|
target_version = read_pyproject_version(core_root(resolved_workspace))
|
||||||
|
except Exception as exc: # noqa: BLE001 - bounded fail-closed diagnostic.
|
||||||
|
collection_errors.append(
|
||||||
|
DashboardCollectionError(
|
||||||
|
code="core_version_metadata_unreadable",
|
||||||
|
message=f"Core version metadata could not be read or parsed ({type(exc).__name__}).",
|
||||||
|
remediation=(
|
||||||
|
"Repair govoplan-core/pyproject.toml, refresh the dashboard, "
|
||||||
|
"then rebuild the release plan."
|
||||||
|
),
|
||||||
|
repo="govoplan-core",
|
||||||
|
source="govoplan-core/pyproject.toml",
|
||||||
|
)
|
||||||
|
)
|
||||||
target_tag = f"v{target_version}" if target_version else None
|
target_tag = f"v{target_version}" if target_version else None
|
||||||
|
|
||||||
repositories = tuple(
|
repositories = tuple(
|
||||||
@@ -38,7 +53,11 @@ def build_dashboard(
|
|||||||
)
|
)
|
||||||
catalog = collect_catalog_snapshot(workspace_root=resolved_workspace, channel=channel, check_public=check_public_catalog)
|
catalog = collect_catalog_snapshot(workspace_root=resolved_workspace, channel=channel, check_public=check_public_catalog)
|
||||||
migrations = collect_migration_snapshots() if include_migrations else ()
|
migrations = collect_migration_snapshots() if include_migrations else ()
|
||||||
contracts = collect_contracts(repositories) if include_contracts else ()
|
if include_contracts:
|
||||||
|
contracts, contract_errors = collect_contracts_with_diagnostics(repositories)
|
||||||
|
collection_errors.extend(contract_errors)
|
||||||
|
else:
|
||||||
|
contracts = ()
|
||||||
return ReleaseDashboard(
|
return ReleaseDashboard(
|
||||||
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
meta_root=str(META_ROOT),
|
meta_root=str(META_ROOT),
|
||||||
@@ -47,28 +66,34 @@ def build_dashboard(
|
|||||||
target_tag=target_tag,
|
target_tag=target_tag,
|
||||||
online=online or check_remote_tags or check_public_catalog,
|
online=online or check_remote_tags or check_public_catalog,
|
||||||
include_migrations=include_migrations,
|
include_migrations=include_migrations,
|
||||||
summary=summarize(repositories, target_tag=target_tag),
|
summary=summarize(repositories, target_tag=target_tag, collection_errors=tuple(collection_errors)),
|
||||||
repositories=repositories,
|
repositories=repositories,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
migrations=migrations,
|
migrations=migrations,
|
||||||
contracts=contracts,
|
contracts=contracts,
|
||||||
|
collection_errors=tuple(collection_errors),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def summarize(repositories: tuple[RepositorySnapshot, ...], *, target_tag: str | None) -> DashboardSummary:
|
def summarize(
|
||||||
|
repositories: tuple[RepositorySnapshot, ...],
|
||||||
|
*,
|
||||||
|
target_tag: str | None,
|
||||||
|
collection_errors: tuple[DashboardCollectionError, ...] = (),
|
||||||
|
) -> DashboardSummary:
|
||||||
missing_count = sum(1 for repo in repositories if not repo.exists or not repo.is_git)
|
missing_count = sum(1 for repo in repositories if not repo.exists or not repo.is_git)
|
||||||
dirty_count = sum(1 for repo in repositories if repo.dirty)
|
dirty_count = sum(1 for repo in repositories if repo.dirty)
|
||||||
ahead_count = sum(1 for repo in repositories if repo.ahead)
|
ahead_count = sum(1 for repo in repositories if repo.ahead)
|
||||||
behind_count = sum(1 for repo in repositories if repo.behind)
|
behind_count = sum(1 for repo in repositories if repo.behind)
|
||||||
no_head_count = sum(1 for repo in repositories if repo.exists and repo.is_git and not repo.has_head)
|
no_head_count = sum(1 for repo in repositories if repo.exists and repo.is_git and not repo.has_head)
|
||||||
error_count = sum(1 for repo in repositories if repo.errors)
|
error_count = sum(1 for repo in repositories if repo.errors) + len(collection_errors)
|
||||||
safe_directory_count = sum(1 for repo in repositories if repo.safe_directory_required)
|
safe_directory_count = sum(1 for repo in repositories if repo.safe_directory_required)
|
||||||
local_target_tag_missing_count = (
|
local_target_tag_missing_count = (
|
||||||
sum(1 for repo in repositories if repo.local_target_tag_exists is False and repo.versions.primary == target_tag.removeprefix("v"))
|
sum(1 for repo in repositories if repo.local_target_tag_exists is False and repo.versions.primary == target_tag.removeprefix("v"))
|
||||||
if target_tag
|
if target_tag
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
if missing_count or behind_count or safe_directory_count:
|
if missing_count or behind_count or safe_directory_count or collection_errors:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif dirty_count or ahead_count or no_head_count or error_count or local_target_tag_missing_count:
|
elif dirty_count or ahead_count or no_head_count or error_count or local_target_tag_missing_count:
|
||||||
status = "attention"
|
status = "attention"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ def collect_repository_snapshot(
|
|||||||
try:
|
try:
|
||||||
versions = collect_versions(path)
|
versions = collect_versions(path)
|
||||||
except Exception as exc: # noqa: BLE001 - surfaced in the dashboard.
|
except Exception as exc: # noqa: BLE001 - surfaced in the dashboard.
|
||||||
errors.append(str(exc))
|
errors.append(f"version metadata could not be read or parsed ({type(exc).__name__})")
|
||||||
versions = VersionSnapshot()
|
versions = VersionSnapshot()
|
||||||
|
|
||||||
return RepositorySnapshot(
|
return RepositorySnapshot(
|
||||||
|
|||||||
@@ -111,6 +111,17 @@ class MigrationTrackSnapshot:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DashboardCollectionError:
|
||||||
|
"""Bounded, actionable failure from read-only dashboard collection."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
remediation: str
|
||||||
|
repo: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DashboardSummary:
|
class DashboardSummary:
|
||||||
repository_count: int
|
repository_count: int
|
||||||
@@ -139,6 +150,7 @@ class ReleaseDashboard:
|
|||||||
catalog: CatalogSnapshot
|
catalog: CatalogSnapshot
|
||||||
migrations: tuple[MigrationTrackSnapshot, ...] = ()
|
migrations: tuple[MigrationTrackSnapshot, ...] = ()
|
||||||
contracts: tuple[ModuleContractSnapshot, ...] = ()
|
contracts: tuple[ModuleContractSnapshot, ...] = ()
|
||||||
|
collection_errors: tuple[DashboardCollectionError, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ def build_release_plan(dashboard: ReleaseDashboard) -> ReleasePlan:
|
|||||||
target_version = dashboard.target_version
|
target_version = dashboard.target_version
|
||||||
target_tag = dashboard.target_tag
|
target_tag = dashboard.target_tag
|
||||||
|
|
||||||
|
for index, error in enumerate(dashboard.collection_errors, start=1):
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"dashboard:{error.code}:{index}",
|
||||||
|
title=f"Release metadata is unreadable in {error.repo or 'the workspace'}",
|
||||||
|
detail=f"{error.message} {error.remediation}",
|
||||||
|
severity="blocker",
|
||||||
|
repo=error.repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for repo in dashboard.repositories:
|
for repo in dashboard.repositories:
|
||||||
if not repo.exists:
|
if not repo.exists:
|
||||||
actions.append(
|
actions.append(
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ def build_selective_release_plan(
|
|||||||
compatibility = compatibility_issues(dashboard)
|
compatibility = compatibility_issues(dashboard)
|
||||||
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
|
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
|
||||||
notes = release_notes(dashboard)
|
notes = release_notes(dashboard)
|
||||||
status = plan_status(units=units, compatibility=compatibility)
|
collection_findings = dashboard_collection_findings(dashboard)
|
||||||
findings = tuple(finding for unit in units for finding in unit.gate_findings)
|
findings = (*collection_findings, *(finding for unit in units for finding in unit.gate_findings))
|
||||||
|
status = plan_status(units=units, compatibility=compatibility, findings=findings)
|
||||||
return SelectiveReleasePlan(
|
return SelectiveReleasePlan(
|
||||||
generated_at=datetime.now(tz=UTC)
|
generated_at=datetime.now(tz=UTC)
|
||||||
.replace(microsecond=0)
|
.replace(microsecond=0)
|
||||||
@@ -74,11 +75,28 @@ def build_selective_release_plan(
|
|||||||
findings=findings,
|
findings=findings,
|
||||||
compatibility=compatibility,
|
compatibility=compatibility,
|
||||||
),
|
),
|
||||||
source_preflight_ready=bool(units)
|
source_preflight_ready=not collection_findings
|
||||||
|
and bool(units)
|
||||||
and all(unit.source_preflight_ready for unit in units),
|
and all(unit.source_preflight_ready for unit in units),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_collection_findings(dashboard: ReleaseDashboard) -> tuple[ReleaseGateFinding, ...]:
|
||||||
|
return tuple(
|
||||||
|
ReleaseGateFinding(
|
||||||
|
code=error.code,
|
||||||
|
severity="blocker",
|
||||||
|
message=error.message,
|
||||||
|
remediation=error.remediation,
|
||||||
|
repo=error.repo,
|
||||||
|
source=error.source,
|
||||||
|
expected="readable release metadata",
|
||||||
|
actual="unreadable",
|
||||||
|
)
|
||||||
|
for error in dashboard.collection_errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_repository_version_gate(
|
def apply_repository_version_gate(
|
||||||
units: tuple[ReleasePlanUnit, ...],
|
units: tuple[ReleasePlanUnit, ...],
|
||||||
*,
|
*,
|
||||||
@@ -620,9 +638,12 @@ def release_notes(dashboard: ReleaseDashboard) -> tuple[str, ...]:
|
|||||||
|
|
||||||
|
|
||||||
def plan_status(
|
def plan_status(
|
||||||
*, units: tuple[ReleasePlanUnit, ...], compatibility: tuple[CompatibilityIssue, ...]
|
*,
|
||||||
|
units: tuple[ReleasePlanUnit, ...],
|
||||||
|
compatibility: tuple[CompatibilityIssue, ...],
|
||||||
|
findings: tuple[ReleaseGateFinding, ...] = (),
|
||||||
) -> str:
|
) -> str:
|
||||||
if any(unit.blockers for unit in units) or any(
|
if any(finding.severity == "blocker" for finding in findings) or any(unit.blockers for unit in units) or any(
|
||||||
issue.severity == "blocker" for issue in compatibility
|
issue.severity == "blocker" for issue in compatibility
|
||||||
):
|
):
|
||||||
return "blocked"
|
return "blocked"
|
||||||
|
|||||||
@@ -968,6 +968,7 @@
|
|||||||
metric("Ahead", s.ahead_count),
|
metric("Ahead", s.ahead_count),
|
||||||
metric("Behind", s.behind_count),
|
metric("Behind", s.behind_count),
|
||||||
metric("No HEAD", s.no_head_count),
|
metric("No HEAD", s.no_head_count),
|
||||||
|
metric("Errors", s.error_count),
|
||||||
metric("Contracts", data.contracts.length),
|
metric("Contracts", data.contracts.length),
|
||||||
].join("");
|
].join("");
|
||||||
releaseState.dashboard = data;
|
releaseState.dashboard = data;
|
||||||
@@ -981,7 +982,7 @@
|
|||||||
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;
|
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;
|
||||||
const gitBlockers = (s.missing_count || 0) + (s.safe_directory_count || 0);
|
const gitBlockers = (s.missing_count || 0) + (s.safe_directory_count || 0);
|
||||||
const branchDrift = (s.ahead_count || 0) + (s.behind_count || 0);
|
const branchDrift = (s.ahead_count || 0) + (s.behind_count || 0);
|
||||||
const releaseBlockers = gitBlockers + (s.behind_count || 0) + (s.no_head_count || 0) + (s.dirty_count || 0);
|
const releaseBlockers = gitBlockers + (s.behind_count || 0) + (s.no_head_count || 0) + (s.dirty_count || 0) + (s.error_count || 0);
|
||||||
const catalog = data.catalog || {};
|
const catalog = data.catalog || {};
|
||||||
const hasCandidate = Boolean(elements.candidateDir?.value.trim());
|
const hasCandidate = Boolean(elements.candidateDir?.value.trim());
|
||||||
const catalogPublished = catalog.catalog_matches_public === true && catalog.keyring_matches_public === true;
|
const catalogPublished = catalog.catalog_matches_public === true && catalog.keyring_matches_public === true;
|
||||||
|
|||||||
Reference in New Issue
Block a user