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

734 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ControlPlane.Core.Models;
using LibGit2Sharp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace ControlPlane.Api.Services;
/// <summary>
/// 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.
/// </summary>
public class PromotionService(IConfiguration config, ILogger<PromotionService> logger)
{
// The ordered promotion ladder — develop is trunk, main is production.
public static readonly string[] Ladder = ["develop", "staging", "uat", "main"];
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()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
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 ────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public Task<List<BranchStatus>> GetLadderStatusAsync(string repoName = "Clarity", CancellationToken ct = default) =>
Task.Run(() => GetLadderStatusCore(repoName, ct), ct);
private List<BranchStatus> 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<BranchStatus>();
for (var i = 0; i < Ladder.Length; i++)
{
ct.ThrowIfCancellationRequested();
var branchName = Ladder[i];
var branch = repo.Branches[branchName];
if (branch?.Tip is null)
{
result.Add(new BranchStatus(branchName, false, null, null, 0, 0, []));
continue;
}
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;
int behind = 0;
CommitInfo[] unreleasedCommits = [];
if (i + 1 < Ladder.Length)
{
var nextBranch = repo.Branches[Ladder[i + 1]];
if (nextBranch?.Tip is not null)
{
var div = repo.ObjectDatabase.CalculateHistoryDivergence(tip, nextBranch.Tip);
ahead = div.AheadBy ?? 0;
behind = div.BehindBy ?? 0;
if (ahead > 0)
{
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();
}
}
}
result.Add(new BranchStatus(branchName, true, tip.Sha[..7], summary,
ahead, behind, unreleasedCommits));
}
return result;
}
// ── Promotion ────────────────────────────────────────────────────────────
/// <summary>
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
/// then pushes. HEAD is never mutated — the working tree stays on develop throughout.
/// 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,
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 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(() => 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;
logger.LogError(ex, "Promotion {From}→{To} failed", from, to);
}
finally
{
await SaveAsync(req);
_lock.Release();
}
return req;
}
private void PromoteCore(
string from,
string to,
string? note,
string repoName,
PromotionRequest req,
Action<string> 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) ────────────────────────────────────────────────
/// <summary>
/// Force-resets <paramref name="branchName"/> to <paramref name="toSha"/> and force-pushes to origin.
/// Used to recover a downstream branch that has drifted from trunk (e.g. after an accidental merge commit).
/// </summary>
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<Commit>(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) ──────────────────────────────────────
/// <summary>
/// Cherry-picks the specified commits from <paramref name="from"/> onto <paramref name="to"/>
/// 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.
/// </summary>
public async Task<PromotionRequest> CherryPickAsync(
string[] shas,
string from,
string to,
string requestedBy,
string? note,
Action<string> 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<string> 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<Commit>(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<Commit>(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<Commit>(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);
}
}
}
// ── Conformance check ────────────────────────────────────────────────────
/// <summary>
/// Evaluates whether all branches in the TBD ladder are in conformance:
/// develop → staging → uat → main must form a strict linear ancestry chain with no divergence.
/// </summary>
public Task<ConformanceReport> GetConformanceAsync(string repoName = "Clarity", CancellationToken ct = default) =>
Task.Run(() => GetConformanceCore(repoName, ct), ct);
private ConformanceReport GetConformanceCore(string repoName, CancellationToken ct)
{
var repoPath = GetRepoPath(repoName);
var checks = new List<BranchConformanceCheck>();
if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath))
{
foreach (var b in Ladder)
checks.Add(new BranchConformanceCheck(b, null, ConformanceViolation.Missing, ConformanceSeverity.Critical,
$"Repository '{repoName}' is not configured or the path does not exist.", 0, 0, null));
return new ConformanceReport(repoName, false, checks.ToArray());
}
using var repo = new Repository(repoPath);
// Fetch latest 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 conformance check failed — continuing with cached refs");
}
for (var i = 0; i < Ladder.Length; i++)
{
ct.ThrowIfCancellationRequested();
var branchName = Ladder[i];
var srcName = i > 0 ? Ladder[i - 1] : null; // predecessor branch (e.g. develop for staging)
var branch = repo.Branches[branchName];
// ── Branch missing ──────────────────────────────────────────────
if (branch?.Tip is null)
{
var srcTip = srcName is not null ? repo.Branches[srcName]?.Tip?.Sha : null;
checks.Add(new BranchConformanceCheck(
branchName, srcName,
ConformanceViolation.Missing,
srcName is null ? ConformanceSeverity.Critical : ConformanceSeverity.Info,
srcName is not null
? $"Branch '{branchName}' does not exist. It should be created from '{srcName}'."
: $"Trunk branch '{branchName}' does not exist — the repository may be empty.",
0, 0, srcTip));
continue;
}
// ── Trunk (develop) — just needs to exist ───────────────────────
if (srcName is null)
{
checks.Add(new BranchConformanceCheck(
branchName, null, ConformanceViolation.OK, ConformanceSeverity.OK,
$"Trunk branch '{branchName}' exists.", 0, 0, null));
continue;
}
var srcBranch = repo.Branches[srcName];
if (srcBranch?.Tip is null)
{
// Source branch is itself missing — skip, it will be reported separately.
checks.Add(new BranchConformanceCheck(
branchName, srcName, ConformanceViolation.OK, ConformanceSeverity.OK,
$"Source branch '{srcName}' is missing — check skipped.", 0, 0, null));
continue;
}
// CalculateHistoryDivergence(srcTip, branchTip):
// AheadBy = commits srcBranch has that branch doesn't → branch is pending promotion (stale)
// BehindBy = commits branch has that srcBranch doesn't → branch is DIVERGED (violation)
var div = repo.ObjectDatabase.CalculateHistoryDivergence(srcBranch.Tip, branch.Tip);
var ahead = div.AheadBy ?? 0;
var behind = div.BehindBy ?? 0;
if (behind > 0)
{
// Downstream has commits the upstream doesn't — TBD violation (broken linear history).
checks.Add(new BranchConformanceCheck(
branchName, srcName,
ConformanceViolation.Diverged, ConformanceSeverity.Critical,
$"'{branchName}' has {behind} commit(s) not reachable from '{srcName}'. " +
$"This breaks TBD linear history. Likely caused by a commit made directly to '{branchName}' " +
$"without backporting to trunk. Fix: reset '{branchName}' to '{srcName}' tip.",
behind, ahead,
srcBranch.Tip.Sha));
}
else if (ahead > 0)
{
// Upstream has unreleased commits — normal TBD state, but flag if count is high.
var sev = ahead > 10 ? ConformanceSeverity.Warning : ConformanceSeverity.Info;
checks.Add(new BranchConformanceCheck(
branchName, srcName,
ConformanceViolation.Stale, sev,
$"'{branchName}' is {ahead} commit(s) behind '{srcName}'. " +
(ahead > 10 ? "Large backlog — consider promoting soon." : "Pending promotion."),
0, ahead, null));
}
else
{
checks.Add(new BranchConformanceCheck(
branchName, srcName, ConformanceViolation.OK, ConformanceSeverity.OK,
$"'{branchName}' is fully in sync with '{srcName}'.", 0, 0, null));
}
}
var isConformant = !checks.Any(c =>
c.Violation is ConformanceViolation.Diverged or ConformanceViolation.Missing);
return new ConformanceReport(repoName, isConformant, checks.ToArray());
}
// ── Create branch ─────────────────────────────────────────────────────────
/// <summary>
/// Creates a new branch at the given commit SHA and pushes it to origin.
/// Used to create missing ladder branches (e.g. staging, uat) from their source branch tip.
/// </summary>
public Task CreateBranchAsync(string branchName, string fromSha, string repoName, CancellationToken ct) =>
Task.Run(() =>
{
var repoPath = GetRepoPath(repoName);
using var repo = new Repository(repoPath);
if (repo.Branches[branchName] is not null)
throw new InvalidOperationException($"Branch '{branchName}' already exists in {repoName}.");
var commit = repo.Lookup<Commit>(fromSha)
?? throw new InvalidOperationException($"SHA '{fromSha}' not found in {repoName}.");
repo.Refs.Add($"refs/heads/{branchName}", commit.Sha);
var remote = repo.Network.Remotes["origin"]
?? throw new InvalidOperationException("No 'origin' remote configured.");
repo.Network.Push(remote, $"refs/heads/{branchName}:refs/heads/{branchName}", MakePushOptions());
logger.LogInformation("Created branch {Branch} at {Sha} in {Repo}", branchName, commit.Sha[..7], repoName);
}, ct);
// ── 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 []; }
}
}
/// <summary>A single unreleased commit — carries full SHA for cherry-pick operations.</summary>
public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Date);
/// <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 (diverged)
CommitInfo[] UnreleasedCommits // rich commit objects for cherry-pick UI
);