From 524f085aca92dd2795cc33da08c05bd4730d680a Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 7 Apr 2026 12:06:58 +0300 Subject: [PATCH] feat(release-editor): visual pipeline editor with smart defaults and strategy visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add GET /releases/latest-by-name endpoint for smart defaults (clone from previous release) - Add GET /releases/suggest-version endpoint with semver auto-increment - Add BumpVersion() logic: patch bump, prerelease increment, date-based build bump - Add ReleaseDefaultsDto with components, strategy, targetEnvironment for pre-fill Frontend — Pipeline Model (release-pipeline.models.ts): - ReleasePipeline, PipelinePhase, DeployConfig discriminated union types - 7 phase types: preflight, gate, approval, deploy, test, promote, seal - 5 deployment strategies: rolling, canary, blue-green, recreate, A/B release - 5 test types: smoke, health-check, integration, canary-metrics, manual - FallbackConfig with behavior (rollback/pause/continue/abort) + autoRollback - PHASE_CATALOG with icons and default configs for drag palette - createDefaultPipeline() generates phase sequence based on release type + strategy Frontend — Pipeline Editor (release-pipeline-editor.component.ts): - Horizontal phase strip with START/END nodes and arrow connectors - Color-coded phase nodes (deploy=blue, test=amber, gate=red, approval=purple, seal=green) - Phase palette dropdown (add preflight/gate/approval/deploy/test/seal phases) - Click-to-configure: deploy strategy selector, test type, approval count, gate toggles - Strategy visualizers: - Rolling: batch nodes with health check arrows - Canary: staged traffic bars (5% → 25% → 50% → 100%) with duration labels - Blue-Green: swim lanes with switch indicator - A/B: variant bars with metrics + winner - Fallback branch visualization (dashed red lines below deploy nodes) - Auto-rollback toggle per phase Frontend — Create Release Wizard Enhancement: - Smart defaults: debounced name lookup (500ms) → pre-fill strategy, target, components - Version suggestion badge ("Use 1.3.1") from previous release version - Clone banner ("Based on Platform Release 1.2.3") - Pipeline editor embedded in Contract step (collapsible "Deployment Pipeline" section) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Endpoints/ReleaseEndpoints.cs | 132 +++++ .../create-release.component.ts | 204 ++++++- .../release-pipeline-editor.component.ts | 551 ++++++++++++++++++ .../create-release/release-pipeline.models.ts | 302 ++++++++++ 4 files changed, 1184 insertions(+), 5 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/release-pipeline-editor.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/release-pipeline.models.ts diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs index 60fd23373..0155899f2 100644 --- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs +++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ReleaseEndpoints.cs @@ -161,6 +161,20 @@ public static class ReleaseEndpoints targets.WithName("Release_AvailableEnvironments"); } + var latestByName = group.MapGet("/latest-by-name", GetLatestByName) + .WithDescription("Return the most recent release with the given name, including structured metadata for smart defaults when creating a new version. Used by the release creation wizard to auto-fill from the previous version."); + if (includeRouteNames) + { + latestByName.WithName("Release_LatestByName"); + } + + var suggestVersion = group.MapGet("/suggest-version", SuggestNextVersion) + .WithDescription("Suggest the next semantic version for a release name based on the latest existing version. Returns the suggested version string and the source release ID."); + if (includeRouteNames) + { + suggestVersion.WithName("Release_SuggestVersion"); + } + var activity = group.MapGet("/activity", ListActivity) .WithDescription("Return a paginated feed of release activities across all releases, optionally filtered by environment, outcome, and time window."); if (includeRouteNames) @@ -642,6 +656,27 @@ public static class ReleaseEndpoints public required string Version { get; init; } } + public sealed record ReleaseDefaultsDto + { + public required string SourceReleaseId { get; init; } + public required string Name { get; init; } + public required string Version { get; init; } + public required string SuggestedNextVersion { get; init; } + public string DeploymentStrategy { get; init; } = "rolling"; + public string? TargetEnvironment { get; init; } + public string? Description { get; init; } + public List Components { get; init; } = new(); + } + + public sealed record ReleaseDefaultComponentDto + { + public required string Name { get; init; } + public required string ImageRef { get; init; } + public string? Tag { get; init; } + public required string Version { get; init; } + public required string Type { get; init; } + } + public sealed record AddComponentDto { public required string Name { get; init; } @@ -704,6 +739,103 @@ public static class ReleaseEndpoints return Results.Ok(new { items, total = sorted.Count }); } + private static IResult GetLatestByName([FromQuery] string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return Results.BadRequest(new { error = "name query parameter is required" }); + } + + var latest = SeedData.Releases + .Where(r => string.Equals(r.Name, name.Trim(), StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r.CreatedAt) + .FirstOrDefault(); + + if (latest is null) + { + return Results.NotFound(new { error = "no_releases_found", name }); + } + + var components = SeedData.Components.TryGetValue(latest.Id, out var comps) + ? comps : new List(); + + return Results.Ok(new ReleaseDefaultsDto + { + SourceReleaseId = latest.Id, + Name = latest.Name, + Version = latest.Version, + SuggestedNextVersion = BumpVersion(latest.Version, latest.Status == "deployed"), + DeploymentStrategy = latest.DeploymentStrategy, + TargetEnvironment = latest.TargetEnvironment ?? latest.CurrentEnvironment, + Description = latest.Description, + Components = components.Select(c => new ReleaseDefaultComponentDto + { + Name = c.Name, + ImageRef = c.ImageRef, + Tag = c.Tag, + Version = c.Version, + Type = c.Type, + }).ToList(), + }); + } + + private static IResult SuggestNextVersion([FromQuery] string? name, [FromQuery] string? currentVersion) + { + string? baseVersion = currentVersion; + + if (string.IsNullOrWhiteSpace(baseVersion) && !string.IsNullOrWhiteSpace(name)) + { + var latest = SeedData.Releases + .Where(r => string.Equals(r.Name, name.Trim(), StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r.CreatedAt) + .FirstOrDefault(); + baseVersion = latest?.Version; + } + + if (string.IsNullOrWhiteSpace(baseVersion)) + { + return Results.Ok(new { suggestedVersion = "1.0.0", source = (string?)null }); + } + + return Results.Ok(new { suggestedVersion = BumpVersion(baseVersion, true), source = baseVersion }); + } + + private static string BumpVersion(string version, bool isStable) + { + // Handle prerelease: 1.3.0-rc1 → 1.3.0-rc2 + var dashIndex = version.IndexOf('-'); + if (dashIndex > 0) + { + var prerelease = version[(dashIndex + 1)..]; + var match = System.Text.RegularExpressions.Regex.Match(prerelease, @"^(.+?)(\d+)$"); + if (match.Success) + { + var prefix = match.Groups[1].Value; + var num = int.Parse(match.Groups[2].Value) + 1; + return $"{version[..dashIndex]}-{prefix}{num}"; + } + // No numeric suffix — strip prerelease (promote to stable) + return version[..dashIndex]; + } + + // Handle semantic: 1.2.3 → 1.2.4 + var parts = version.Split('.'); + if (parts.Length >= 3 && int.TryParse(parts[^1], out var patch)) + { + parts[^1] = (patch + 1).ToString(); + return string.Join('.', parts); + } + + // Handle date-based: 2026.02.20.1 → 2026.02.20.2 + if (parts.Length >= 2 && int.TryParse(parts[^1], out var build)) + { + parts[^1] = (build + 1).ToString(); + return string.Join('.', parts); + } + + return version + ".1"; + } + // ---- Seed Data ---- internal static class SeedData diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts index b9ac4a0b6..07dcf8955 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts @@ -1,7 +1,8 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { catchError, debounceTime, distinctUntilChanged, finalize, map, of, Subject, switchMap, throwError } from 'rxjs'; import { ReleaseManagementStore } from '../release.store'; import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from '../../../../core/api/release-management.models'; @@ -11,10 +12,14 @@ import { type ReleaseControlBundleVersionDetailDto, } from '../../../bundles/bundle-organizer.api'; import { PlatformContextStore } from '../../../../core/context/platform-context.store'; +import { ScriptPickerComponent } from '../../../../shared/components/script-picker/script-picker.component'; +import { ReleasePipelineEditorComponent } from './release-pipeline-editor.component'; +import { type ReleasePipeline, type ReleaseDefaults, createDefaultPipeline } from './release-pipeline.models'; +import type { Script } from '../../../../core/api/scripts.models'; @Component({ selector: 'app-create-release', - imports: [FormsModule, RouterModule], + imports: [FormsModule, RouterModule, ScriptPickerComponent, ReleasePipelineEditorComponent], template: `
@@ -61,14 +66,28 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.

Define the canonical identity fields for this version. All fields marked * are required.

+ @if (releaseDefaults()) { +
+ + Based on {{ releaseDefaults()!.name }} {{ releaseDefaults()!.version }} — defaults pre-filled. Edit any field to customize. +
+ } +
@@ -660,6 +679,36 @@ import { PlatformContextStore } from '../../../../core/context/platform-context. Optional JSON to merge into the release contract +
+ + + Lifecycle Scripts + +
+

Attach scripts from the registry to run at each stage of the deployment lifecycle.

+
+ + + + +
+
+
+ + +
+ + + Deployment Pipeline + +
+

Define the phases your release will go through: gate checks, approvals, deployment, testing, and evidence sealing. Click a phase to configure it.

+ +
+
+