Files
OPC/ControlPlane.Api/Endpoints/PromotionEndpoints.cs
T
2026-04-26 00:38:10 -04:00

189 lines
8.7 KiB
C#

using ControlPlane.Api.Services;
using ControlPlane.Core.Models;
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<string?>(
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<string?>(
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 });
}
});
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);