feat(release): guide selective gate remediation
This commit is contained in:
@@ -39,6 +39,16 @@ shows the dry-run commands for the selected rows, and `Generate Candidate`
|
||||
creates a signed catalog candidate that advances only selected repositories that
|
||||
already have a catalog entry.
|
||||
|
||||
`Build Plan` also returns structured release-gate findings for each selected
|
||||
repository. The plan names the recommended next action and gives an explicit
|
||||
remediation for source-version, lockfile, Core WebUI composition, Git state, and
|
||||
worktree findings. A target version that has not yet been applied consistently
|
||||
to the selected source tree is therefore explained before the source-tag
|
||||
preflight rather than appearing only as an error after the operator tries to
|
||||
tag. `source_preflight_ready` means that the plan-visible source gates pass; the
|
||||
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
||||
immutable-tag checks.
|
||||
|
||||
The release-control area above the repository table is read-only and is meant
|
||||
to become the central release cockpit. It shows:
|
||||
|
||||
|
||||
138
tests/test_release_plan_guidance.py
Normal file
138
tests/test_release_plan_guidance.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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 govoplan_release.model import ( # noqa: E402
|
||||
CatalogSnapshot,
|
||||
DashboardSummary,
|
||||
ReleaseDashboard,
|
||||
RepositorySnapshot,
|
||||
RepositorySpec,
|
||||
VersionSnapshot,
|
||||
)
|
||||
from govoplan_release.selective_planner import build_selective_release_plan # noqa: E402
|
||||
|
||||
|
||||
class ReleasePlanGuidanceTests(unittest.TestCase):
|
||||
def test_unprepared_target_has_structured_remediation_and_recommendation(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repo_path = workspace / "govoplan-files"
|
||||
repo_path.mkdir()
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
dashboard(workspace=workspace, version="1.2.3"),
|
||||
selected_repos=("govoplan-files",),
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", plan.status)
|
||||
self.assertFalse(plan.source_preflight_ready)
|
||||
finding = next(
|
||||
item
|
||||
for item in plan.gate_findings
|
||||
if item.code == "repository_version_alignment"
|
||||
)
|
||||
self.assertEqual("govoplan-files", finding.repo)
|
||||
self.assertEqual("pyproject.toml", finding.source)
|
||||
self.assertEqual("1.2.4", finding.expected)
|
||||
self.assertEqual("1.2.3", finding.actual)
|
||||
self.assertIn("Regenerate lockfiles", finding.remediation)
|
||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
||||
self.assertEqual("govoplan-files", plan.recommended_action.repo)
|
||||
|
||||
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
workspace = Path(temp_dir)
|
||||
repo_path = workspace / "govoplan-files"
|
||||
repo_path.mkdir()
|
||||
(repo_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
dashboard(workspace=workspace, version="1.2.4"),
|
||||
selected_repos=("govoplan-files",),
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
)
|
||||
|
||||
self.assertTrue(plan.source_preflight_ready)
|
||||
self.assertEqual("preview_source_release", plan.recommended_action.id)
|
||||
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
|
||||
|
||||
def test_webui_renders_recommendation_and_remediation(self) -> None:
|
||||
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("plan.gate_findings", webui)
|
||||
self.assertIn("plan.recommended_action", webui)
|
||||
self.assertIn("recommended next", webui)
|
||||
self.assertIn('plan.status === "blocked" ? "block"', webui)
|
||||
self.assertIn('pill("recommended next", recommendationKind)', webui)
|
||||
self.assertIn("<strong>Remediation:</strong>", webui)
|
||||
|
||||
|
||||
def dashboard(*, workspace: Path, version: str) -> ReleaseDashboard:
|
||||
repo = RepositorySnapshot(
|
||||
spec=RepositorySpec(
|
||||
name="govoplan-files",
|
||||
category="module",
|
||||
subtype="infrastructure",
|
||||
remote="git@example.test:add-ideas/govoplan-files.git",
|
||||
path="govoplan-files",
|
||||
),
|
||||
absolute_path=str(workspace / "govoplan-files"),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(pyproject=version),
|
||||
local_target_tag_exists=False,
|
||||
)
|
||||
return ReleaseDashboard(
|
||||
generated_at="2026-07-22T00:00:00Z",
|
||||
meta_root=str(workspace / "govoplan"),
|
||||
workspace_root=str(workspace),
|
||||
target_version=None,
|
||||
target_tag=None,
|
||||
online=False,
|
||||
include_migrations=False,
|
||||
summary=DashboardSummary(
|
||||
repository_count=1,
|
||||
missing_count=0,
|
||||
dirty_count=0,
|
||||
ahead_count=0,
|
||||
behind_count=0,
|
||||
no_head_count=0,
|
||||
error_count=0,
|
||||
safe_directory_count=0,
|
||||
local_target_tag_missing_count=0,
|
||||
status="ready",
|
||||
),
|
||||
repositories=(repo,),
|
||||
catalog=CatalogSnapshot(
|
||||
channel="stable",
|
||||
website_path="",
|
||||
catalog_path="",
|
||||
catalog_exists=False,
|
||||
keyring_path="",
|
||||
keyring_exists=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -251,6 +251,14 @@ class VersionAlignmentTests(unittest.TestCase):
|
||||
self.assertEqual("blocked", plan.status)
|
||||
self.assertEqual("blocked", plan.units[0].status)
|
||||
self.assertTrue(any("Core release WebUI dependency" in item for item in plan.units[0].blockers))
|
||||
finding = next(
|
||||
item
|
||||
for item in plan.gate_findings
|
||||
if item.code == "release_webui_composition_alignment"
|
||||
)
|
||||
self.assertIn("package.release.json", finding.source)
|
||||
self.assertIn("regenerate webui/package-lock.release.json", finding.remediation)
|
||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
||||
|
||||
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -186,6 +186,18 @@ class ModuleContractSnapshot:
|
||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseGateFinding:
|
||||
code: str
|
||||
severity: str
|
||||
message: str
|
||||
remediation: str
|
||||
repo: str | None = None
|
||||
source: str | None = None
|
||||
expected: str | None = None
|
||||
actual: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleasePlanUnit:
|
||||
repo: str
|
||||
@@ -200,6 +212,8 @@ class ReleasePlanUnit:
|
||||
warnings: tuple[str, ...] = ()
|
||||
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||
source_preflight_ready: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -224,6 +238,15 @@ class ReleasePlanStep:
|
||||
status: str = "planned"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseRecommendedAction:
|
||||
id: str
|
||||
title: str
|
||||
detail: str
|
||||
remediation: str
|
||||
repo: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectiveReleasePlan:
|
||||
generated_at: str
|
||||
@@ -233,6 +256,9 @@ class SelectiveReleasePlan:
|
||||
compatibility: tuple[CompatibilityIssue, ...]
|
||||
dry_run_steps: tuple[ReleasePlanStep, ...]
|
||||
notes: tuple[str, ...] = ()
|
||||
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||
recommended_action: ReleaseRecommendedAction | None = None
|
||||
source_preflight_ready: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -12,14 +12,19 @@ from .model import (
|
||||
CompatibilityIssue,
|
||||
InterfaceProviderSnapshot,
|
||||
InterfaceRequirementSnapshot,
|
||||
ReleaseGateFinding,
|
||||
ReleaseDashboard,
|
||||
ReleasePlanStep,
|
||||
ReleasePlanUnit,
|
||||
ReleaseRecommendedAction,
|
||||
RepositorySnapshot,
|
||||
SelectiveReleasePlan,
|
||||
ModuleContractSnapshot,
|
||||
)
|
||||
from .version_alignment import selected_release_webui_bundle_issues
|
||||
from .version_alignment import (
|
||||
selected_release_webui_bundle_issues,
|
||||
selected_repository_version_issues,
|
||||
)
|
||||
|
||||
|
||||
def build_selective_release_plan(
|
||||
@@ -37,11 +42,13 @@ def build_selective_release_plan(
|
||||
build_unit(repo, target_version=repo_versions.get(repo.spec.name) or target_version, contracts=contracts_by_repo.get(repo.spec.name))
|
||||
for repo in repositories
|
||||
)
|
||||
units = apply_repository_version_gate(units, workspace=Path(dashboard.workspace_root))
|
||||
units = apply_release_webui_bundle_gate(units, workspace=Path(dashboard.workspace_root))
|
||||
compatibility = compatibility_issues(dashboard)
|
||||
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
|
||||
notes = release_notes(dashboard)
|
||||
status = plan_status(units=units, compatibility=compatibility)
|
||||
findings = tuple(finding for unit in units for finding in unit.gate_findings)
|
||||
return SelectiveReleasePlan(
|
||||
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
target_channel=channel,
|
||||
@@ -50,6 +57,59 @@ def build_selective_release_plan(
|
||||
compatibility=compatibility,
|
||||
dry_run_steps=steps,
|
||||
notes=notes,
|
||||
gate_findings=findings,
|
||||
recommended_action=recommended_action(
|
||||
units=units,
|
||||
findings=findings,
|
||||
compatibility=compatibility,
|
||||
),
|
||||
source_preflight_ready=bool(units) and all(unit.source_preflight_ready for unit in units),
|
||||
)
|
||||
|
||||
|
||||
def apply_repository_version_gate(
|
||||
units: tuple[ReleasePlanUnit, ...],
|
||||
*,
|
||||
workspace: Path,
|
||||
) -> tuple[ReleasePlanUnit, ...]:
|
||||
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||
for issue in selected_repository_version_issues(
|
||||
repo_versions={unit.repo: unit.target_version for unit in units},
|
||||
workspace=workspace,
|
||||
):
|
||||
issues_by_repo.setdefault(issue.repo, []).append(
|
||||
ReleaseGateFinding(
|
||||
code="repository_version_alignment",
|
||||
severity="blocker",
|
||||
message=issue.message,
|
||||
remediation=(
|
||||
f"Align {issue.source} and every other version-bearing source declaration to "
|
||||
f"{issue.expected!r}. Regenerate lockfiles instead of editing resolved entries by hand, "
|
||||
"commit the reviewed changes, then rebuild this plan."
|
||||
),
|
||||
repo=issue.repo,
|
||||
source=issue.source,
|
||||
expected=issue.expected,
|
||||
actual=issue.actual,
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
replace(
|
||||
unit,
|
||||
status="blocked",
|
||||
blockers=(
|
||||
*unit.blockers,
|
||||
*(
|
||||
f"{finding.source}={finding.actual!r}, expected {finding.expected!r} ({finding.message})"
|
||||
for finding in issues_by_repo[unit.repo]
|
||||
),
|
||||
),
|
||||
gate_findings=(*unit.gate_findings, *issues_by_repo[unit.repo]),
|
||||
source_preflight_ready=False,
|
||||
)
|
||||
if unit.repo in issues_by_repo
|
||||
else unit
|
||||
for unit in units
|
||||
)
|
||||
|
||||
|
||||
@@ -58,19 +118,39 @@ def apply_release_webui_bundle_gate(
|
||||
*,
|
||||
workspace: Path,
|
||||
) -> tuple[ReleasePlanUnit, ...]:
|
||||
issues_by_repo: dict[str, list[str]] = {}
|
||||
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||
for issue in selected_release_webui_bundle_issues(
|
||||
repo_versions={unit.repo: unit.target_version for unit in units},
|
||||
workspace=workspace,
|
||||
):
|
||||
issues_by_repo.setdefault(issue.repo, []).append(
|
||||
f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
||||
ReleaseGateFinding(
|
||||
code="release_webui_composition_alignment",
|
||||
severity="blocker",
|
||||
message=issue.message,
|
||||
remediation=(
|
||||
"Update Core's webui/package.release.json dependency to the selected module tag, "
|
||||
"regenerate webui/package-lock.release.json, commit both files, then rebuild this plan."
|
||||
),
|
||||
repo=issue.repo,
|
||||
source=issue.source,
|
||||
expected=issue.expected,
|
||||
actual=issue.actual,
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
replace(
|
||||
unit,
|
||||
status="blocked",
|
||||
blockers=(*unit.blockers, *issues_by_repo[unit.repo]),
|
||||
blockers=(
|
||||
*unit.blockers,
|
||||
*(
|
||||
f"{finding.source}={finding.actual!r}, expected {finding.expected!r} ({finding.message})"
|
||||
for finding in issues_by_repo[unit.repo]
|
||||
),
|
||||
),
|
||||
gate_findings=(*unit.gate_findings, *issues_by_repo[unit.repo]),
|
||||
source_preflight_ready=False,
|
||||
)
|
||||
if unit.repo in issues_by_repo
|
||||
else unit
|
||||
@@ -95,16 +175,57 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
|
||||
target_tag = f"v{resolved_target}"
|
||||
blockers: list[str] = []
|
||||
warnings: list[str] = []
|
||||
findings: list[ReleaseGateFinding] = []
|
||||
if not repo.exists:
|
||||
blockers.append("repository is missing locally")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_missing",
|
||||
message="Repository is missing locally.",
|
||||
remediation="Clone or bootstrap the registered repository, then refresh the dashboard.",
|
||||
)
|
||||
)
|
||||
elif not repo.is_git:
|
||||
blockers.append("path is not a Git repository")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_not_git",
|
||||
message="Registered path is not a Git repository.",
|
||||
remediation="Restore the registered checkout as a Git repository, then refresh the dashboard.",
|
||||
)
|
||||
)
|
||||
elif repo.safe_directory_required:
|
||||
blockers.append("Git safe.directory approval is required")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="git_safe_directory",
|
||||
message="Git safe.directory approval is required.",
|
||||
remediation="Review checkout ownership and apply Git safe.directory approval only if this path is trusted.",
|
||||
)
|
||||
)
|
||||
elif repo.behind:
|
||||
blockers.append(f"repository is behind {repo.upstream}")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_behind",
|
||||
message=f"Repository is behind {repo.upstream}.",
|
||||
remediation="Fetch and integrate the upstream changes, then rebuild the plan from the reviewed HEAD.",
|
||||
)
|
||||
)
|
||||
if repo.exists and repo.is_git and not repo.has_head:
|
||||
blockers.append("repository has no initial commit")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_no_head",
|
||||
message="Repository has no initial commit.",
|
||||
remediation="Create and publish a reviewed initial commit before planning its first release.",
|
||||
)
|
||||
)
|
||||
local_versions = tuple(
|
||||
value
|
||||
for value in (
|
||||
@@ -118,12 +239,57 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
|
||||
)
|
||||
if len(set(local_versions)) > 1:
|
||||
blockers.append("backend, manifest, and frontend version metadata is not aligned")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_version_alignment",
|
||||
message="Backend, manifest, and frontend version metadata is not aligned.",
|
||||
remediation=f"Align every version-bearing source declaration to {resolved_target!r}, regenerate lockfiles, and rebuild the plan.",
|
||||
)
|
||||
)
|
||||
if repo.exists and repo.is_git and repo.has_head and not repo.branch:
|
||||
blockers.append("repository is not on a named branch")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_detached_head",
|
||||
message="Repository is not on a named branch.",
|
||||
remediation="Check out the intended release branch, then rebuild the plan.",
|
||||
)
|
||||
)
|
||||
if repo.dirty:
|
||||
warnings.append("uncommitted changes will need review before release")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="worktree_dirty",
|
||||
severity="warning",
|
||||
message="Uncommitted changes prevent source tagging.",
|
||||
remediation="Preview Prepare Changes, review the exact paths, then commit or discard them before rebuilding the plan.",
|
||||
)
|
||||
)
|
||||
if repo.ahead:
|
||||
warnings.append("unpushed commits should be pushed before catalog publication")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="repository_ahead",
|
||||
severity="warning",
|
||||
message="The branch contains unpushed commits.",
|
||||
remediation="Preview the source release; guarded publication can push the branch and annotated tag atomically.",
|
||||
)
|
||||
)
|
||||
if current_version is None:
|
||||
warnings.append("no local version metadata found; initial release needs version metadata and catalog entry synthesis")
|
||||
findings.append(
|
||||
gate_finding(
|
||||
repo=repo.spec.name,
|
||||
code="version_metadata_missing",
|
||||
severity="warning",
|
||||
message="No local version metadata was discovered.",
|
||||
remediation=f"Add the required package and manifest version declarations for {resolved_target!r}, then rebuild the plan.",
|
||||
)
|
||||
)
|
||||
if current_version and current_version == resolved_target and repo.local_target_tag_exists:
|
||||
warnings.append(f"local tag {target_tag} already exists")
|
||||
|
||||
@@ -146,6 +312,85 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
|
||||
warnings=tuple(warnings),
|
||||
provides_interfaces=provides,
|
||||
requires_interfaces=requires,
|
||||
gate_findings=tuple(findings),
|
||||
source_preflight_ready=(
|
||||
not blockers
|
||||
and not repo.dirty
|
||||
and repo.has_head
|
||||
and bool(repo.branch)
|
||||
and current_version == resolved_target
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def gate_finding(
|
||||
*,
|
||||
repo: str,
|
||||
code: str,
|
||||
message: str,
|
||||
remediation: str,
|
||||
severity: str = "blocker",
|
||||
) -> ReleaseGateFinding:
|
||||
return ReleaseGateFinding(
|
||||
code=code,
|
||||
severity=severity,
|
||||
message=message,
|
||||
remediation=remediation,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
|
||||
def recommended_action(
|
||||
*,
|
||||
units: tuple[ReleasePlanUnit, ...],
|
||||
findings: tuple[ReleaseGateFinding, ...],
|
||||
compatibility: tuple[CompatibilityIssue, ...],
|
||||
) -> ReleaseRecommendedAction:
|
||||
blocker = next(
|
||||
(finding for finding in findings if finding.severity == "blocker"), None
|
||||
)
|
||||
if blocker is not None:
|
||||
return ReleaseRecommendedAction(
|
||||
id="resolve_release_gate",
|
||||
title=f"Resolve {blocker.repo or 'release'} gate",
|
||||
detail=blocker.message,
|
||||
remediation=blocker.remediation,
|
||||
repo=blocker.repo,
|
||||
)
|
||||
contract_blocker = next(
|
||||
(issue for issue in compatibility if issue.severity == "blocker"), None
|
||||
)
|
||||
if contract_blocker is not None:
|
||||
return ReleaseRecommendedAction(
|
||||
id="resolve_compatibility_gate",
|
||||
title="Resolve interface compatibility gate",
|
||||
detail=contract_blocker.message,
|
||||
remediation="Align the selected provider and consumer interface contracts, then rebuild the plan before publication.",
|
||||
repo=contract_blocker.repo,
|
||||
)
|
||||
worktree = next(
|
||||
(finding for finding in findings if finding.code == "worktree_dirty"), None
|
||||
)
|
||||
if worktree is not None:
|
||||
return ReleaseRecommendedAction(
|
||||
id="prepare_changes",
|
||||
title=f"Prepare {worktree.repo} changes",
|
||||
detail=worktree.message,
|
||||
remediation=worktree.remediation,
|
||||
repo=worktree.repo,
|
||||
)
|
||||
if not units:
|
||||
return ReleaseRecommendedAction(
|
||||
id="select_release_targets",
|
||||
title="Select release targets",
|
||||
detail="No repositories are selected for this release plan.",
|
||||
remediation="Select one or more repositories, confirm their target versions, then build the plan again.",
|
||||
)
|
||||
return ReleaseRecommendedAction(
|
||||
id="preview_source_release",
|
||||
title="Preview source release tags",
|
||||
detail="All plan-visible source preflight gates pass for the selected repositories.",
|
||||
remediation="Run Preview Tag + Publish to verify manifest scope, configured remotes, and immutable local/remote tag state before mutation.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -409,6 +409,11 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.action.recommended {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.action h3 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 13px;
|
||||
@@ -1409,10 +1414,28 @@
|
||||
const units = plan.units || [];
|
||||
const compatibility = plan.compatibility || [];
|
||||
const steps = plan.dry_run_steps || [];
|
||||
if (!units.length && !compatibility.length && !steps.length) {
|
||||
const findings = plan.gate_findings || [];
|
||||
const recommendation = plan.recommended_action || null;
|
||||
if (!units.length && !compatibility.length && !steps.length && !recommendation) {
|
||||
elements.actions.innerHTML = `<div class="muted">No selective release candidates. Select repositories above to plan a release.</div>`;
|
||||
return;
|
||||
}
|
||||
const recommendationKind = plan.status === "blocked" ? "block" : plan.source_preflight_ready ? "ok" : "warn";
|
||||
const recommendationHtml = recommendation ? `<div class="action recommended">
|
||||
<h3>${pill("recommended next", recommendationKind)} ${escapeHtml(recommendation.title)}</h3>
|
||||
<p>${escapeHtml(recommendation.detail)}</p>
|
||||
<p class="action-meta"><strong>How to continue:</strong> ${escapeHtml(recommendation.remediation)}</p>
|
||||
</div>` : "";
|
||||
const findingHtml = findings.map((finding) => {
|
||||
const kind = finding.severity === "blocker" ? "block" : finding.severity === "warning" ? "warn" : "ok";
|
||||
const source = finding.source ? `${finding.source}: ${finding.actual || "-"} -> ${finding.expected || "-"}` : "";
|
||||
return `<div class="action">
|
||||
<h3>${pill(finding.severity, kind)} ${escapeHtml(finding.repo || "release")} · ${escapeHtml(finding.code)}</h3>
|
||||
<p>${escapeHtml(finding.message)}</p>
|
||||
${source ? `<p class="action-meta">${escapeHtml(source)}</p>` : ""}
|
||||
<p class="action-meta"><strong>Remediation:</strong> ${escapeHtml(finding.remediation)}</p>
|
||||
</div>`;
|
||||
}).join("");
|
||||
const unitHtml = units.map((unit) => {
|
||||
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
|
||||
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
|
||||
@@ -1439,6 +1462,8 @@
|
||||
</div>`;
|
||||
}).join("");
|
||||
elements.actions.innerHTML = `
|
||||
${recommendationHtml}
|
||||
${findingHtml}
|
||||
${unitHtml}
|
||||
${compatibilityHtml}
|
||||
${stepHtml}
|
||||
|
||||
Reference in New Issue
Block a user