Files
OPC/ControlPlane.Api/Services/PromotionService.cs
T
2026-04-25 18:05:57 -04:00

284 lines
11 KiB
C#

using ControlPlane.Core.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text.Json;
namespace ControlPlane.Api.Services;
/// <summary>
/// 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.
/// </summary>
public class PromotionService(IConfiguration config, ILogger<PromotionService> logger)
{
// The ordered promotion ladder — each step is a valid promotion.
public static readonly string[] Ladder = ["develop", "staging", "uat", "master"];
private string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
private static readonly SemaphoreSlim _lock = new(1, 1);
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
// ── Branch status ────────────────────────────────────────────────────────
/// <summary>
/// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch.
/// </summary>
public async Task<List<BranchStatus>> GetLadderStatusAsync(CancellationToken ct = default)
{
var result = new List<BranchStatus>();
// 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)
{
var exists = await BranchExistsAsync(branch, ct);
if (!exists)
{
result.Add(new BranchStatus(branch, 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]);
}
// 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)
{
var next = Ladder[nextIdx];
if (await BranchExistsAsync(next, ct))
{
var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct);
if (!string.IsNullOrWhiteSpace(counts))
{
var parts = counts.Trim().Split('\t');
if (parts.Length == 2)
{
int.TryParse(parts[0], out behind);
int.TryParse(parts[1], out ahead);
}
}
}
}
// 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));
}
return result;
}
// ── Promotion ────────────────────────────────────────────────────────────
/// <summary>
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
/// then pushes. Streams progress lines to <paramref name="onLine"/>.
/// </summary>
public async Task<PromotionRequest> PromoteAsync(
string from,
string to,
string requestedBy,
string? note,
Action<string> onLine,
CancellationToken ct)
{
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
{
var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed };
busy.Log.Add("⚠️ Another promotion 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
{
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;
}
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
{
await SaveAsync(req);
_lock.Release();
}
return req;
}
// ── History persistence ──────────────────────────────────────────────────
private string HistoryPath
{
get
{
var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
Directory.CreateDirectory(folder);
return Path.Combine(folder, "promotions.json");
}
}
private static readonly SemaphoreSlim _fileLock = new(1, 1);
private async Task SaveAsync(PromotionRequest req)
{
await _fileLock.WaitAsync();
try
{
var all = LoadHistory();
var idx = all.FindIndex(r => r.Id == req.Id);
if (idx >= 0) all[idx] = req; else all.Insert(0, req);
if (all.Count > 100) all = all[..100];
await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(all, JsonOpts));
}
finally { _fileLock.Release(); }
}
public async Task<List<PromotionRequest>> GetHistoryAsync()
{
await _fileLock.WaitAsync();
try { return LoadHistory(); }
finally { _fileLock.Release(); }
}
private List<PromotionRequest> LoadHistory()
{
if (!File.Exists(HistoryPath)) return [];
try { return JsonSerializer.Deserialize<List<PromotionRequest>>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; }
catch { return []; }
}
// ── Git helpers ──────────────────────────────────────────────────────────
private async Task<bool> BranchExistsAsync(string branch, CancellationToken ct)
{
var output = await GitOutputAsync($"branch --list {branch}", ct);
return !string.IsNullOrWhiteSpace(output);
}
private async Task<string> 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,
};
}
/// <summary>Current status of a single branch in the promotion ladder.</summary>
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
);