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.

+ +
+
+