diff --git a/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs b/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs index 6ee8604..53efd57 100644 --- a/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs +++ b/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; using ControlPlane.Api.Services; +using ControlPlane.Core.Services; namespace ControlPlane.Api.Endpoints; @@ -14,6 +15,7 @@ public static class ImageBuildEndpoints var group = app.MapGroup("/api/image").WithTags("Image"); group.MapGet("/status", GetStatus); + group.MapGet("/history", GetHistory); group.MapPost("/build", TriggerBuild); // Post-provisioning verification helpers @@ -28,6 +30,26 @@ public static class ImageBuildEndpoints private static async Task GetStatus(ImageBuildService svc) => Results.Ok(await svc.GetStatusAsync()); + /// Returns recent DockerImage build records for the sparkline chart. + private static async Task GetHistory(BuildHistoryService history, int limit = 30) + { + var all = await history.GetBuildsAsync(); + var records = all + .Where(b => b.Kind == ControlPlane.Core.Models.BuildKind.DockerImage) + .Take(Math.Clamp(limit, 1, 100)) + .Select(b => new + { + b.Id, + b.Status, + b.StartedAt, + b.DurationMs, + b.CommitSha, + b.ImageDigest, + }) + .ToList(); + return Results.Ok(records); + } + /// /// Triggers a docker build and streams the output line-by-line as SSE. /// The build context is the repo root, which must be configured via diff --git a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs index 68b64c6..0414469 100644 --- a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs +++ b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs @@ -12,9 +12,9 @@ public static class PromotionEndpoints { var g = app.MapGroup("/api/promotions").WithTags("Promotions"); - // GET /api/promotions/ladder — branch status for all 4 ladder branches - g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct) => - Results.Ok(await svc.GetLadderStatusAsync(ct))); + // 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) => @@ -50,7 +50,7 @@ public static class PromotionEndpoints 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), ct) + 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)) @@ -66,8 +66,84 @@ public static class PromotionEndpoints 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); + }); + return app; } } -public record PromoteRequest(string From, string To, string? RequestedBy, string? Note); +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); diff --git a/ControlPlane.Api/Services/ImageBuildService.cs b/ControlPlane.Api/Services/ImageBuildService.cs index 00f0395..f65783b 100644 --- a/ControlPlane.Api/Services/ImageBuildService.cs +++ b/ControlPlane.Api/Services/ImageBuildService.cs @@ -2,6 +2,7 @@ using ControlPlane.Core.Models; using ControlPlane.Core.Services; using Docker.DotNet; using Docker.DotNet.Models; +using LibGit2Sharp; namespace ControlPlane.Api.Services; @@ -49,6 +50,14 @@ public class ImageBuildService( var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName); + // Capture HEAD SHA so the build is traceable back to a specific commit + try + { + using var repo = new Repository(repoRoot); + record.CommitSha = repo.Head.Tip?.Sha; + } + catch { /* not a git repo or no commits yet — CommitSha stays null */ } + try { var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine"; diff --git a/ControlPlane.Api/Services/PromotionService.cs b/ControlPlane.Api/Services/PromotionService.cs index e790f4c..151a5e7 100644 --- a/ControlPlane.Api/Services/PromotionService.cs +++ b/ControlPlane.Api/Services/PromotionService.cs @@ -1,22 +1,25 @@ using ControlPlane.Core.Models; +using LibGit2Sharp; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using System.Diagnostics; using System.Text.Json; namespace ControlPlane.Api.Services; /// -/// Handles all git operations for the promotion workflow: -/// branch status, diff summaries, merge + push, and promotion history persistence. -/// All git commands run against the repo root configured in Docker:RepoRoot. +/// Handles all git operations for the promotion workflow using LibGit2Sharp. +/// No git.exe subprocess is ever spawned — all operations run through the managed +/// LibGit2Sharp API against the server's authoritative repository clone. +/// HEAD is never mutated; merges are performed directly on the object database +/// so the working tree always reflects the develop branch. /// public class PromotionService(IConfiguration config, ILogger logger) { - // The ordered promotion ladder — each step is a valid promotion. - public static readonly string[] Ladder = ["develop", "staging", "uat", "master"]; + // The ordered promotion ladder — develop is trunk, main is production. + public static readonly string[] Ladder = ["develop", "staging", "uat", "main"]; - private string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty; + private string GetRepoPath(string repoName) => + config[$"Git:Repos:{repoName}"] ?? string.Empty; private static readonly SemaphoreSlim _lock = new(1, 1); private static readonly JsonSerializerOptions JsonOpts = new() @@ -26,67 +29,116 @@ public class PromotionService(IConfiguration config, ILogger l Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; + // ── Credentials ────────────────────────────────────────────────────────── + + private FetchOptions MakeFetchOptions() => new() + { + CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials + { + Username = config["Gitea:Owner"] ?? "git", + Password = config["Gitea:Token"] ?? string.Empty, + }, + }; + + private PushOptions MakePushOptions() => new() + { + CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials + { + Username = config["Gitea:Owner"] ?? "git", + Password = config["Gitea:Token"] ?? string.Empty, + }, + }; + + private static Signature MakeSig() => + new("OPC Control Plane", "opc@clarity.internal", DateTimeOffset.UtcNow); + // ── Branch status ──────────────────────────────────────────────────────── /// /// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch. + /// Runs on a thread-pool thread because LibGit2Sharp network operations are synchronous. /// - public async Task> GetLadderStatusAsync(CancellationToken ct = default) + public Task> GetLadderStatusAsync(string repoName = "Clarity", CancellationToken ct = default) => + Task.Run(() => GetLadderStatusCore(repoName, ct), ct); + + private List GetLadderStatusCore(string repoName, CancellationToken ct) { + var repoPath = GetRepoPath(repoName); + if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath)) + return Ladder.Select(b => new BranchStatus(b, false, null, null, 0, 0, [])).ToList(); + + using var repo = new Repository(repoPath); + + // Fetch to get up-to-date remote refs; swallow network errors so status still works offline. + try + { + var remote = repo.Network.Remotes["origin"]; + if (remote is not null) + { + var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList(); + repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions()); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Fetch during ladder status failed — continuing with cached refs"); + } + var result = new List(); - // Fetch to get up-to-date remote state, but don't fail if we're offline - await RunGitAsync("fetch --all --quiet", ct, swallowErrors: true); - - foreach (var branch in Ladder) + for (var i = 0; i < Ladder.Length; i++) { - var exists = await BranchExistsAsync(branch, ct); - if (!exists) + ct.ThrowIfCancellationRequested(); + + var branchName = Ladder[i]; + var branch = repo.Branches[branchName]; + + if (branch?.Tip is null) { - result.Add(new BranchStatus(branch, false, null, null, 0, 0, [])); + result.Add(new BranchStatus(branchName, false, null, null, 0, 0, [])); continue; } - // Last commit on this branch - var lastCommit = await GitOutputAsync($"log {branch} -1 --format=%h|%an|%ad|%s --date=short", ct); - string? shortHash = null, author = null, date = null, subject = null; - if (!string.IsNullOrWhiteSpace(lastCommit)) - { - var p = lastCommit.Trim().Split('|', 4); - if (p.Length == 4) (shortHash, author, date, subject) = (p[0], p[1], p[2], p[3]); - } + var tip = branch.Tip; + var when = tip.Author.When; + var summary = $"{tip.Author.Name} · {when:yyyy-MM-dd} · {tip.MessageShort}"; - // Ahead/behind vs the NEXT branch in the ladder - int ahead = 0, behind = 0; - var nextIdx = Array.IndexOf(Ladder, branch) + 1; - if (nextIdx < Ladder.Length) + // Ahead/behind vs the next branch in the ladder + int ahead = 0; + int behind = 0; + CommitInfo[] unreleasedCommits = []; + + if (i + 1 < Ladder.Length) { - var next = Ladder[nextIdx]; - if (await BranchExistsAsync(next, ct)) + var nextBranch = repo.Branches[Ladder[i + 1]]; + if (nextBranch?.Tip is not null) { - var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct); - if (!string.IsNullOrWhiteSpace(counts)) + var div = repo.ObjectDatabase.CalculateHistoryDivergence(tip, nextBranch.Tip); + ahead = div.AheadBy ?? 0; + behind = div.BehindBy ?? 0; + + if (ahead > 0) { - var parts = counts.Trim().Split('\t'); - if (parts.Length == 2) - { - int.TryParse(parts[0], out behind); - int.TryParse(parts[1], out ahead); - } + unreleasedCommits = repo.Commits + .QueryBy(new CommitFilter + { + IncludeReachableFrom = tip, + ExcludeReachableFrom = nextBranch.Tip, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, + }) + .Select(c => new CommitInfo( + c.Sha, + c.Sha[..7], + c.MessageShort, + c.Author.Name, + c.Author.When.ToString("yyyy-MM-dd"))) + .ToArray(); } } } - // Unreleased commit summaries (commits in this branch not yet in next) - string[] unreleasedLines = []; - if (ahead > 0 && nextIdx < Ladder.Length && await BranchExistsAsync(Ladder[nextIdx], ct)) - { - var log = await GitOutputAsync($"log {Ladder[nextIdx]}..{branch} --oneline --no-decorate", ct); - unreleasedLines = log.Split('\n', StringSplitOptions.RemoveEmptyEntries); - } - - result.Add(new BranchStatus(branch, true, shortHash, $"{author} · {date} · {subject}", - ahead, behind, unreleasedLines)); + result.Add(new BranchStatus(branchName, true, tip.Sha[..7], summary, + ahead, behind, unreleasedCommits)); } return result; @@ -96,7 +148,8 @@ public class PromotionService(IConfiguration config, ILogger l /// /// Merges into with a no-fast-forward merge commit, - /// then pushes. Streams progress lines to . + /// then pushes. HEAD is never mutated — the working tree stays on develop throughout. + /// Streams progress lines to . /// public async Task PromoteAsync( string from, @@ -104,7 +157,8 @@ public class PromotionService(IConfiguration config, ILogger l string requestedBy, string? note, Action onLine, - CancellationToken ct) + CancellationToken ct, + string repoName = "Clarity") { if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) { @@ -126,57 +180,13 @@ public class PromotionService(IConfiguration config, ILogger l try { - Log($"▶ Promoting {from} → {to}"); - if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}"); - Log("──────────────────────────────────────"); - - // 1. Fetch latest - Log(" git fetch --all"); - await RunGitAsync("fetch --all --quiet", ct); - - // 2. Checkout target branch - Log($" git checkout {to}"); - await RunGitAsync($"checkout {to}", ct); - - // 3. Pull target to latest - Log($" git pull origin {to}"); - await RunGitAsync($"pull origin {to} --quiet", ct); - - // 4. Count commits being promoted - var logOutput = await GitOutputAsync($"log {to}..{from} --oneline --no-decorate", ct); - var commitLines = logOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries); - req.CommitCount = commitLines.Length; - req.CommitLines = commitLines; - Log($" Merging {commitLines.Length} commit(s) from {from}:"); - foreach (var cl in commitLines) Log($" {cl}"); - - // 5. Merge with --no-ff for a clean promotion commit - var mergeMsg = $"chore: promote {from} → {to}" + (note != null ? $" — {note}" : ""); - Log($" git merge --no-ff {from}"); - await RunGitAsync($"merge --no-ff {from} -m \"{mergeMsg}\"", ct); - - // 6. Push - Log($" git push origin {to}"); - await RunGitAsync($"push origin {to}", ct); - - // 7. Return to develop so the working tree stays clean - await RunGitAsync("checkout develop", ct, swallowErrors: true); - - Log("──────────────────────────────────────"); - Log($"✔ {from} → {to} promoted successfully at {DateTimeOffset.UtcNow:u}"); - req.Status = PromotionStatus.Succeeded; - req.CompletedAt = DateTimeOffset.UtcNow; + await Task.Run(() => PromoteCore(from, to, note, repoName, req, Log, ct), ct); } catch (Exception ex) { Log($"✖ Promotion failed: {ex.Message}"); req.Status = PromotionStatus.Failed; req.CompletedAt = DateTimeOffset.UtcNow; - - // Try to abort any broken merge state - await RunGitAsync("merge --abort", ct, swallowErrors: true); - await RunGitAsync("checkout develop", ct, swallowErrors: true); - logger.LogError(ex, "Promotion {From}→{To} failed", from, to); } finally @@ -188,6 +198,329 @@ public class PromotionService(IConfiguration config, ILogger l return req; } + private void PromoteCore( + string from, + string to, + string? note, + string repoName, + PromotionRequest req, + Action Log, + CancellationToken ct) + { + Log($"▶ Promoting {from} → {to} [{repoName}]"); + if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}"); + Log("──────────────────────────────────────"); + + using var repo = new Repository(GetRepoPath(repoName)); + + // 1. Fetch latest remote state for all branches + Log(" Fetching origin..."); + var remote = repo.Network.Remotes["origin"] + ?? throw new InvalidOperationException("No 'origin' remote configured."); + var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList(); + repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions()); + + // 2. Resolve local branches + var fromBranch = repo.Branches[from] + ?? throw new InvalidOperationException($"Branch '{from}' not found."); + var toBranch = repo.Branches[to] + ?? throw new InvalidOperationException($"Branch '{to}' not found."); + + // 3. Fast-forward local `to` to its remote tracking branch (equivalent to git pull --ff-only) + var remoteTracking = repo.Branches[$"origin/{to}"]; + if (remoteTracking?.Tip is not null && toBranch.Tip.Sha != remoteTracking.Tip.Sha) + { + Log($" Fast-forwarding {to} to origin/{to}..."); + repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, remoteTracking.Tip.Sha); + toBranch = repo.Branches[to]!; // refresh after update + } + + ct.ThrowIfCancellationRequested(); + + var fromTip = fromBranch.Tip; + var toTip = toBranch.Tip; + + // 4. Enumerate commits being promoted + var pendingCommits = repo.Commits.QueryBy(new CommitFilter + { + IncludeReachableFrom = fromTip, + ExcludeReachableFrom = toTip, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, + }).ToList(); + + if (pendingCommits.Count == 0) + { + Log($" ℹ {to} is already up-to-date with {from}. Nothing to promote."); + req.Status = PromotionStatus.Succeeded; + req.CommitCount = 0; + req.CommitLines = []; + req.CompletedAt = DateTimeOffset.UtcNow; + return; + } + + req.CommitCount = pendingCommits.Count; + req.CommitLines = pendingCommits.Select(c => $"{c.Sha[..7]} {c.MessageShort}").ToArray(); + Log($" {pendingCommits.Count} commit(s) to promote:"); + foreach (var cl in req.CommitLines) Log($" {cl}"); + + ct.ThrowIfCancellationRequested(); + + // 5. Safety check: `from` must be a descendant of `to` (fast-forward is only possible + // when the target branch has no commits that aren't already reachable from source). + // This is the TBD invariant — staging/uat/main are always subsets of develop's linear history. + var isAncestor = repo.ObjectDatabase.FindMergeBase(fromTip, toTip)?.Sha == toTip.Sha; + if (!isAncestor) + { + throw new InvalidOperationException( + $"'{to}' has commits not in '{from}' — fast-forward is not possible. " + + $"This means '{to}' diverged from trunk. " + + $"Check whether a hotfix was committed directly to '{to}' without being backported to '{from}'."); + } + + // 6. Fast-forward: advance the local `to` ref to `from`'s tip — no merge commit, linear history. + // Equivalent to: git push origin {from}:{to} + // HEAD is never mutated, working tree is untouched. + var oldToSha = toTip.Sha; + repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, fromTip.Sha); + Log($" Fast-forward: refs/heads/{to} {oldToSha[..7]} → {fromTip.Sha[..7]}"); + + ct.ThrowIfCancellationRequested(); + + // 7. Push to origin; roll back the local ref if push fails so nothing is left half-done + Log($" Pushing {to} to origin..."); + try + { + repo.Network.Push(remote, $"refs/heads/{to}:refs/heads/{to}", MakePushOptions()); + } + catch + { + repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, oldToSha); + throw; + } + + Log("──────────────────────────────────────"); + Log($"✔ {from} → {to} promoted successfully ({pendingCommits.Count} commit(s)) at {DateTimeOffset.UtcNow:u}"); + req.Status = PromotionStatus.Succeeded; + req.CompletedAt = DateTimeOffset.UtcNow; + } + + // ── Branch reset (recovery) ──────────────────────────────────────────────── + + /// + /// Force-resets to and force-pushes to origin. + /// Used to recover a downstream branch that has drifted from trunk (e.g. after an accidental merge commit). + /// + public Task ResetBranchAsync(string branchName, string toSha, string repoName, CancellationToken ct) => + Task.Run(() => + { + var repoPath = GetRepoPath(repoName); + using var repo = new Repository(repoPath); + + var commit = repo.Lookup(toSha) + ?? throw new InvalidOperationException($"SHA '{toSha}' not found in {repoName}."); + + var branch = repo.Branches[branchName] + ?? throw new InvalidOperationException($"Branch '{branchName}' not found in {repoName}."); + + var oldSha = branch.Tip.Sha; + repo.Refs.UpdateTarget(branch.Reference.CanonicalName, commit.Sha); + + try + { + var remote = repo.Network.Remotes["origin"] + ?? throw new InvalidOperationException("No 'origin' remote."); + // Force push — "+" prefix overrides remote reflog + repo.Network.Push(remote, $"+refs/heads/{branchName}:refs/heads/{branchName}", MakePushOptions()); + } + catch + { + repo.Refs.UpdateTarget(branch.Reference.CanonicalName, oldSha); + throw; + } + + logger.LogInformation("Reset {Branch} from {Old} to {New} in {Repo}", branchName, oldSha[..7], commit.Sha[..7], repoName); + }, ct); + + // ── Cherry-pick (partial promotion) ────────────────────────────────────── + + /// + /// Cherry-picks the specified commits from onto + /// and pushes. Unlike a full fast-forward promotion, cherry-pick copies individual commits + /// as new commits — useful for promoting a subset of changes to a downstream environment. + /// Note: cherry-pick will cause the target branch to diverge from trunk. + /// + public async Task CherryPickAsync( + string[] shas, + string from, + string to, + string requestedBy, + string? note, + Action onLine, + CancellationToken ct, + string repoName = "Clarity") + { + if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) + { + var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed }; + busy.Log.Add("⚠️ Another promotion or cherry-pick is already in progress."); + return busy; + } + + var req = new PromotionRequest + { + FromBranch = from, + ToBranch = to, + RequestedBy = requestedBy, + Note = note, + Status = PromotionStatus.Running, + }; + + void Log(string line) { req.Log.Add(line); onLine(line); } + + try + { + await Task.Run(() => CherryPickCore(shas, from, to, repoName, req, Log, ct), ct); + } + catch (Exception ex) + { + Log($"✖ Cherry-pick failed: {ex.Message}"); + req.Status = PromotionStatus.Failed; + req.CompletedAt = DateTimeOffset.UtcNow; + logger.LogError(ex, "Cherry-pick {From}→{To} failed", from, to); + } + finally + { + await SaveAsync(req); + _lock.Release(); + } + + return req; + } + + private void CherryPickCore( + string[] shas, + string from, + string to, + string repoName, + PromotionRequest req, + Action Log, + CancellationToken ct) + { + Log($"▶ Cherry-pick {shas.Length} commit(s): {from} → {to} [{repoName}]"); + if (!string.IsNullOrWhiteSpace(req.Note)) Log($" Note: {req.Note}"); + Log("──────────────────────────────────────"); + + using var repo = new Repository(GetRepoPath(repoName)); + + if (repo.Info.IsBare) + throw new InvalidOperationException( + "Cherry-pick requires a non-bare repository clone. " + + "Ensure Git:Repos:{name} points to a standard (non-bare) clone."); + + // 1. Fetch + Log(" Fetching origin..."); + var remote = repo.Network.Remotes["origin"] + ?? throw new InvalidOperationException("No 'origin' remote configured."); + var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList(); + repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions()); + + // 2. Resolve target branch + var toBranch = repo.Branches[to] + ?? throw new InvalidOperationException($"Branch '{to}' not found."); + + // 3. Fast-forward `to` to its remote tracking branch (sync with origin) + var remoteTracking = repo.Branches[$"origin/{to}"]; + if (remoteTracking?.Tip is not null && toBranch.Tip.Sha != remoteTracking.Tip.Sha) + { + Log($" Fast-forwarding {to} to origin/{to}..."); + repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, remoteTracking.Tip.Sha); + toBranch = repo.Branches[to]!; + } + + var savedToSha = toBranch.Tip.Sha; + var originalHeadBranchName = repo.Head.FriendlyName; + + // 4. Resolve commits — shas arrive newest-first from UI (topological order); + // reverse so we apply oldest → newest (preserves logical order in history). + var commitsOrdered = shas + .Select(sha => repo.Lookup(sha) + ?? throw new InvalidOperationException($"Commit '{sha}' not found in {repoName}.")) + .Reverse() + .ToList(); + + req.CommitCount = commitsOrdered.Count; + req.CommitLines = commitsOrdered.Select(c => $"{c.Sha[..7]} {c.MessageShort}").ToArray(); + Log($" {commitsOrdered.Count} commit(s) to apply (oldest → newest):"); + foreach (var c in commitsOrdered) Log($" {c.Sha[..7]} {c.MessageShort}"); + + ct.ThrowIfCancellationRequested(); + + // 5. Force-checkout target branch — discards any dirty working tree state left by a + // previous failed cherry-pick or interrupted operation. This is a server-only clone + // managed exclusively by the control plane, so force is always safe here. + Log($" Checking out {to} (force)..."); + var forceCheckout = new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }; + Commands.Checkout(repo, toBranch, forceCheckout); + + try + { + var sig = MakeSig(); + foreach (var commit in commitsOrdered) + { + ct.ThrowIfCancellationRequested(); + Log($" Applying {commit.Sha[..7]} {commit.MessageShort}..."); + + var result = repo.CherryPick(commit, sig); + switch (result.Status) + { + case CherryPickStatus.CherryPicked: + Log($" \u2714 \u2192 {result.Commit!.Sha[..7]}"); + break; + case CherryPickStatus.Conflicts: + Log($" \u2716 Conflict \u2014 aborting and rolling back"); + repo.Reset(ResetMode.Hard, repo.Lookup(savedToSha)); + throw new InvalidOperationException( + $"Cherry-pick conflict on {commit.Sha[..7]}: {commit.MessageShort}. " + + "Resolve conflicts manually or promote a different set of commits."); + default: + Log($" \u2261 Already present or no changes \u2014 skipped"); + break; + } + } + + // 6. Push + Log($" Pushing {to} to origin..."); + try + { + repo.Network.Push(remote, $"refs/heads/{to}:refs/heads/{to}", MakePushOptions()); + } + catch + { + repo.Reset(ResetMode.Hard, repo.Lookup(savedToSha)); + throw; + } + + Log("──────────────────────────────────────"); + Log($"✔ Cherry-picked {commitsOrdered.Count} commit(s) to {to} at {DateTimeOffset.UtcNow:u}"); + req.Status = PromotionStatus.Succeeded; + req.CompletedAt = DateTimeOffset.UtcNow; + } + finally + { + // Always restore HEAD to the original branch regardless of outcome + try + { + var headBranch = repo.Branches[originalHeadBranchName]; + if (headBranch is not null) + Commands.Checkout(repo, headBranch, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not restore HEAD to '{Branch}' after cherry-pick", originalHeadBranchName); + } + } + } + // ── History persistence ────────────────────────────────────────────────── private string HistoryPath @@ -230,54 +563,18 @@ public class PromotionService(IConfiguration config, ILogger l try { return JsonSerializer.Deserialize>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; } catch { return []; } } - - // ── Git helpers ────────────────────────────────────────────────────────── - - private async Task BranchExistsAsync(string branch, CancellationToken ct) - { - var output = await GitOutputAsync($"branch --list {branch}", ct); - return !string.IsNullOrWhiteSpace(output); - } - - private async Task GitOutputAsync(string args, CancellationToken ct) - { - var psi = MakePsi(args); - using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git"); - var output = await proc.StandardOutput.ReadToEndAsync(ct); - await proc.WaitForExitAsync(ct); - return output; - } - - private async Task RunGitAsync(string args, CancellationToken ct, bool swallowErrors = false) - { - var psi = MakePsi(args); - using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git"); - var stderr = await proc.StandardError.ReadToEndAsync(ct); - await proc.WaitForExitAsync(ct); - - if (!swallowErrors && proc.ExitCode != 0) - throw new InvalidOperationException($"git {args} exited {proc.ExitCode}: {stderr.Trim()}"); - - logger.LogDebug("git {Args} → exit {Code}", args, proc.ExitCode); - } - - private ProcessStartInfo MakePsi(string args) => new("git", args) - { - WorkingDirectory = RepoRoot, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; } +/// A single unreleased commit — carries full SHA for cherry-pick operations. +public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Date); + /// Current status of a single branch in the promotion ladder. public record BranchStatus( - string Branch, - bool Exists, - string? ShortHash, - string? LastCommitSummary, - int AheadOfNext, // commits this branch has that the next doesn't - int BehindNext, // commits next has that this branch doesn't (shouldn't happen in clean flow) - string[] UnreleasedLines // oneline log of the ahead commits + string Branch, + bool Exists, + string? ShortHash, + string? LastCommitSummary, + int AheadOfNext, // commits this branch has that the next doesn't + int BehindNext, // commits next has that this branch doesn't (diverged) + CommitInfo[] UnreleasedCommits // rich commit objects for cherry-pick UI ); diff --git a/ControlPlane.Core/Models/BuildRecord.cs b/ControlPlane.Core/Models/BuildRecord.cs index c1b8ba8..f4e731c 100644 --- a/ControlPlane.Core/Models/BuildRecord.cs +++ b/ControlPlane.Core/Models/BuildRecord.cs @@ -20,5 +20,6 @@ public class BuildRecord public DateTimeOffset? FinishedAt { get; set; } public int? DurationMs { get; set; } public string? ImageDigest { get; set; } // populated for DockerImage builds + public string? CommitSha { get; set; } // HEAD SHA at build time public List Log { get; set; } = []; } diff --git a/clarity.controlplane/src/api/provisioningApi.ts b/clarity.controlplane/src/api/provisioningApi.ts index b5f724a..10280b3 100644 --- a/clarity.controlplane/src/api/provisioningApi.ts +++ b/clarity.controlplane/src/api/provisioningApi.ts @@ -201,14 +201,37 @@ export async function getGitLog(path?: string, limit = 20): Promise // ── Promotion / Branch Ladder API ──────────────────────────────────────────── +export interface BuildHistoryRecord { + id: string; + status: 'Running' | 'Succeeded' | 'Failed'; + startedAt: string; + durationMs: number | null; + commitSha: string | null; + imageDigest: string | null; +} + +export async function getImageBuildHistory(limit = 30): Promise { + const res = await fetch(`${BASE_URL}/api/image/history?limit=${limit}`); + if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`); + return res.json(); +} + +export interface CommitInfo { + sha: string; + shortSha: string; + message: string; + author: string; + date: string; +} + export interface BranchStatus { - branch: string; - exists: boolean; - shortHash: string | null; - lastCommitSummary: string | null; - aheadOfNext: number; - behindNext: number; - unreleasedLines: string[]; + branch: string; + exists: boolean; + shortHash: string | null; + lastCommitSummary: string | null; + aheadOfNext: number; + behindNext: number; + unreleasedCommits: CommitInfo[]; } export interface PromotionRecord { @@ -225,8 +248,8 @@ export interface PromotionRecord { log: string[]; } -export async function getLadderStatus(): Promise { - const res = await fetch(`${BASE_URL}/api/promotions/ladder`); +export async function getLadderStatus(repo = 'Clarity'): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/ladder?repo=${encodeURIComponent(repo)}`); if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`); return res.json(); } @@ -246,6 +269,7 @@ export function triggerPromotion( onLine: (line: string) => void, onDone: (record: PromotionRecord) => void, onError: (err: string) => void, + repo = 'Clarity', ): () => void { let cancelled = false; const controller = new AbortController(); @@ -255,7 +279,73 @@ export function triggerPromotion( const res = await fetch(`${BASE_URL}/api/promotions/promote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from, to, requestedBy, note }), + body: JSON.stringify({ from, to, requestedBy, note, repo }), + signal: controller.signal, + }); + + if (!res.ok || !res.body) { onError(res.statusText); return; } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (!cancelled) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const chunk of parts) { + const dataLine = chunk.replace(/^data:\s*/m, '').trim(); + if (!dataLine) continue; + try { + const msg = JSON.parse(dataLine); + if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord); + else if (typeof msg.line === 'string') onLine(msg.line); + } catch { /* skip */ } + } + } + } catch (e) { + if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error'); + } + })(); + + return () => { cancelled = true; controller.abort(); }; +} + +export async function resetBranch(branch: string, toSha: string, repo: string): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch, toSha, repo }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? res.statusText); + } +} + +/** Cherry-picks the specified commits (by full SHA) from `from` to `to` and streams SSE progress. */ +export function triggerCherryPick( + shas: string[], + from: string, + to: string, + requestedBy: string, + note: string | undefined, + onLine: (line: string) => void, + onDone: (record: PromotionRecord) => void, + onError: (err: string) => void, + repo = 'Clarity', +): () => void { + let cancelled = false; + const controller = new AbortController(); + + (async () => { + try { + const res = await fetch(`${BASE_URL}/api/promotions/cherry-pick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shas, from, to, requestedBy, note, repo }), signal: controller.signal, }); diff --git a/clarity.controlplane/src/pages/BranchPage.tsx b/clarity.controlplane/src/pages/BranchPage.tsx index 43f8ca9..3e20267 100644 --- a/clarity.controlplane/src/pages/BranchPage.tsx +++ b/clarity.controlplane/src/pages/BranchPage.tsx @@ -1,21 +1,25 @@ -import { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { - Button, Callout, Intent, Tag, Spinner, + Button, Callout, Checkbox, Intent, Tag, Spinner, Dialog, DialogBody, DialogFooter, - HTMLTable, Collapse, Card, Elevation, TextArea, + HTMLTable, Collapse, TextArea, SegmentedControl, } from '@blueprintjs/core'; import { - getLadderStatus, getPromotionHistory, triggerPromotion, - type BranchStatus, type PromotionRecord, + getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick, + getImageBuildHistory, resetBranch, + type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord, } from '../api/provisioningApi'; -// ── Constants ───────────────────────────────────────────────────────────────── +// ── Constants ───────────────────────────────────────────────────────────────── -const LADDER: { branch: string; label: string; env: string; intent: Intent }[] = [ - { branch: 'develop', label: 'Develop', env: 'fdev', intent: Intent.PRIMARY }, - { branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING }, - { branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER }, - { branch: 'master', label: 'Master', env: 'prod', intent: Intent.SUCCESS }, +const REPOS = ['Clarity', 'OPC', 'Gateway'] as const; +type RepoName = typeof REPOS[number]; + +const LADDER: { branch: string; label: string; env: string; intent: Intent; color: string }[] = [ + { branch: 'develop', label: 'Develop', env: 'dev', intent: Intent.PRIMARY, color: '#215db0' }, + { branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING, color: '#935610' }, + { branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER, color: '#8e292c' }, + { branch: 'main', label: 'Main', env: 'prod', intent: Intent.SUCCESS, color: '#1c6e42' }, ]; const STATUS_INTENT: Record = { @@ -25,92 +29,145 @@ const STATUS_INTENT: Record = { Pending: Intent.NONE, }; -// ── Promotion terminal ──────────────────────────────────────────────────────── +const BUILD_COLOR: Record = { + Succeeded: '#1c6e42', + Failed: '#c23030', + Running: '#2d72d2', +}; + +// -- BuildSparkline ----------------------------------------------------------- + +const MAX_BAR_H = 44; +const BAR_W = 6; +const BAR_GAP = 2; +const N_BARS = 20; + +function BuildSparkline({ builds }: { builds: BuildHistoryRecord[] }) { + const recent = builds.slice(0, N_BARS).reverse(); + const maxMs = Math.max(...recent.map((b) => b.durationMs ?? 0), 1); + + return ( +
+ {recent.map((b) => { + const h = b.durationMs ? Math.max(4, Math.round((b.durationMs / maxMs) * MAX_BAR_H)) : 4; + const color = BUILD_COLOR[b.status] ?? '#8f99a8'; + const date = b.startedAt ? new Date(b.startedAt).toLocaleDateString() : ''; + const dur = b.durationMs != null ? `${(b.durationMs / 1000).toFixed(1)}s` : '-'; + const sha = b.commitSha ? b.commitSha.slice(0, 7) : '\u2014'; + return ( +
+ ); + })} +
+ ); +} + +// -- PromotionTerminal -------------------------------------------------------- function PromotionTerminal({ lines }: { lines: string[] }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]); return ( -
- {lines.length === 0 - ? Waiting for promotion output… - : lines.map((l, i) => { - const color = l.startsWith('✔') ? '#3fb950' - : l.startsWith('✖') ? '#f85149' - : l.startsWith('⚠') ? '#d29922' - : l.startsWith('──') ? '#484f58' - : undefined; - return
{l}
; - }) - } +
+ {lines.length === 0 ? ( + Waiting for output\u2026 + ) : ( + lines.map((l, i) => { + const color = + l.startsWith('[ok]') ? '#3fb950' : + l.startsWith('[err]') ? '#f85149' : + l.startsWith('[warn]') ? '#d29922' : + l.startsWith('--') ? '#8f99a8' : + undefined; + return
{l}
; + }) + )}
); } -// ── Promote dialog ──────────────────────────────────────────────────────────── +// -- PromoteDialog ------------------------------------------------------------ function PromoteDialog({ - from, to, onClose, onDone, + from, to, repo, onClose, onDone, }: { - from: string; to: string; + from: string; to: string; repo: RepoName; onClose: () => void; - onDone: () => void; + onDone: () => void; }) { - const [note, setNote] = useState(''); - const [running, setRunning] = useState(false); - const [logs, setLogs] = useState([]); - const [done, setDone] = useState(false); - const [error, setError] = useState(null); + const [note, setNote] = useState(''); + const [running, setRunning] = useState(false); + const [logs, setLogs] = useState([]); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); const cancelRef = useRef<(() => void) | null>(null); + const fromMeta = LADDER.find((l) => l.branch === from); + const toMeta = LADDER.find((l) => l.branch === to); + const handlePromote = () => { setRunning(true); setLogs([]); setError(null); - cancelRef.current = triggerPromotion( from, to, 'control-plane', note || undefined, - (line) => setLogs((p) => [...p, line]), - () => { setRunning(false); setDone(true); onDone(); }, - (err) => { setError(err); setRunning(false); }, + (line) => setLogs((p) => [...p, line]), + (_record) => { setRunning(false); setDone(true); onDone(); }, + (err) => { setError(err); setRunning(false); }, + repo, ); }; - const handleClose = () => { - cancelRef.current?.(); - onClose(); - }; - - const fromLabel = LADDER.find((l) => l.branch === from)?.label ?? from; - const toLabel = LADDER.find((l) => l.branch === to)?.label ?? to; + const handleClose = () => { cancelRef.current?.(); onClose(); }; return ( + {from} + {'\u2192'} + {to} + [{repo}] + + } + style={{ width: 580 }} > {!running && !done && ( <> -

- This will merge {from} into {to} with a - no-fast-forward commit and push to origin. +

+ Merges {from} into {to} via no-fast-forward and pushes to origin.