OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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
|
||||
);
|
||||
Reference in New Issue
Block a user