using ControlPlane.Api.Services; using ControlPlane.Core.Models; using ControlPlane.Core.Services; using System.Text.Json; namespace ControlPlane.Api.Endpoints; public static class PromotionEndpoints { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public static IEndpointRouteBuilder MapPromotionEndpoints(this IEndpointRouteBuilder app) { var g = app.MapGroup("/api/promotions").WithTags("Promotions"); // GET /api/promotions/ladder?repo=Clarity — branch status for all 4 ladder branches g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct, string repo = "Clarity") => Results.Ok(await svc.GetLadderStatusAsync(repo, ct))); // GET /api/promotions/history g.MapGet("/history", async (PromotionService svc) => Results.Ok(await svc.GetHistoryAsync())); // POST /api/promotions/promote — body: { from, to, requestedBy, note } // Streams SSE log lines then sends {done, promotion} when complete g.MapPost("/promote", async ( HttpContext ctx, PromotionService svc, PromoteRequest req, CancellationToken ct) => { // Validate ladder step var ladder = PromotionService.Ladder; var fi = Array.IndexOf(ladder, req.From); var ti = Array.IndexOf(ladder, req.To); if (fi < 0 || ti < 0 || ti != fi + 1) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync( new { error = $"Invalid promotion step: {req.From} → {req.To}. Must be adjacent in ladder." }, ct); return; } ctx.Response.Headers.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; ctx.Response.Headers.Connection = "keep-alive"; var channel = System.Threading.Channels.Channel.CreateUnbounded( new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); void OnLine(string line) => channel.Writer.TryWrite(line); var promoteTask = Task.Run(() => svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct, req.Repo ?? "Clarity"), ct) .ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default); await foreach (var line in channel.Reader.ReadAllAsync(ct)) { var json = JsonSerializer.Serialize(new { line }, JsonOpts); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } var promotion = await promoteTask; var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts); await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); }); // POST /api/promotions/reset — body: { branch, toSha, repo } // Force-resets a downstream branch to a specific SHA (e.g. to recover from a GitFlow merge commit). // Only allowed for staging/uat — never develop or main. g.MapPost("/reset", async (PromotionService svc, ResetBranchRequest req, CancellationToken ct) => { var allowed = new[] { "staging", "uat" }; if (!allowed.Contains(req.Branch)) return Results.BadRequest(new { error = $"Reset is only allowed for: {string.Join(", ", allowed)}." }); try { await svc.ResetBranchAsync(req.Branch, req.ToSha, req.Repo ?? "Clarity", ct); return Results.Ok(new { reset = req.Branch, toSha = req.ToSha }); } catch (Exception ex) { return Results.BadRequest(new { error = ex.Message }); } }); // POST /api/promotions/cherry-pick — body: { shas, from, to, requestedBy, note, repo } // Streams SSE log lines then sends {done, promotion} when complete. // Unlike a full promote, cherry-pick applies selected commits as copies — branches will diverge. g.MapPost("/cherry-pick", async ( HttpContext ctx, PromotionService svc, CherryPickRequest req, CancellationToken ct) => { var ladder = PromotionService.Ladder; var fi = Array.IndexOf(ladder, req.From); var ti = Array.IndexOf(ladder, req.To); if (fi < 0 || ti < 0 || ti != fi + 1) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync( new { error = $"Invalid cherry-pick target: {req.From} → {req.To}. Must be adjacent in ladder." }, ct); return; } if (req.Shas is null || req.Shas.Length == 0) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync( new { error = "No commits specified for cherry-pick." }, ct); return; } ctx.Response.Headers.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; ctx.Response.Headers.Connection = "keep-alive"; var channel = System.Threading.Channels.Channel.CreateUnbounded( new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); void OnLine(string line) => channel.Writer.TryWrite(line); var cpTask = Task.Run(() => svc.CherryPickAsync(req.Shas, req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct, req.Repo ?? "Clarity"), ct) .ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default); await foreach (var line in channel.Reader.ReadAllAsync(ct)) { var json = JsonSerializer.Serialize(new { line }, JsonOpts); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } var promotion = await cpTask; var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts); await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); }); // GET /api/promotions/conformance?repo=Clarity // Returns a full TBD conformance report: which branches are diverged, missing, or stale. g.MapGet("/conformance", async (PromotionService svc, CancellationToken ct, string repo = "Clarity") => Results.Ok(await svc.GetConformanceAsync(repo, ct))); // GET /api/promotions/conformance/all // Returns conformance reports for all configured repos (Clarity, OPC, Gateway). g.MapGet("/conformance/all", async (PromotionService svc, IConfiguration config, CancellationToken ct) => { var allRepos = new[] { "Clarity", "OPC", "Gateway" }; var configured = allRepos .Where(r => !string.IsNullOrWhiteSpace(config[$"Git:Repos:{r}"])) .ToArray(); var tasks = configured.Select(r => svc.GetConformanceAsync(r, ct)); var results = await Task.WhenAll(tasks); return Results.Ok(results); }); // POST /api/promotions/create-branch — body: { branch, fromSha, repo } // Creates a missing ladder branch at the given SHA and pushes to origin. g.MapPost("/create-branch", async (PromotionService svc, CreateLadderBranchRequest req, CancellationToken ct) => { var allowed = new[] { "staging", "uat", "main" }; if (!allowed.Contains(req.Branch)) return Results.BadRequest(new { error = $"Create-branch is only allowed for: {string.Join(", ", allowed)}." }); try { await svc.CreateBranchAsync(req.Branch, req.FromSha, req.Repo ?? "Clarity", ct); return Results.Ok(new { created = req.Branch, fromSha = req.FromSha }); } catch (Exception ex) { return Results.BadRequest(new { error = ex.Message }); } }); // GET /api/promotions/build-gate?sha={sha} // Returns the build-gate status for the given commit SHA. // If status is "Red", the promote button in the UI should be disabled. g.MapGet("/build-gate", async (string sha, BuildHistoryService history, CancellationToken ct) => { var builds = await history.GetBuildsByShaAsync(sha); var latest = builds.MaxBy(b => b.StartedAt); if (latest is null) return Results.Ok(new { status = "Unknown", sha, buildId = (string?)null, buildStatus = (string?)null }); var gateStatus = latest.Status switch { BuildStatus.Succeeded => "Green", BuildStatus.Failed => "Red", BuildStatus.Running => "Running", _ => "Unknown", }; return Results.Ok(new { status = gateStatus, sha, buildId = latest.Id, buildStatus = latest.Status.ToString() }); }); return app; } } public record PromoteRequest(string From, string To, string? RequestedBy, string? Note, string? Repo); public record ResetBranchRequest(string Branch, string ToSha, string? Repo); public record CherryPickRequest(string[] Shas, string From, string To, string? RequestedBy, string? Note, string? Repo); public record CreateLadderBranchRequest(string Branch, string FromSha, string? Repo);