Files
2026-04-26 14:30:10 -04:00

864 lines
38 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// ── Remote URL (config-driven, never reads .git/config URL) ──────────────
/// <summary>
/// Builds the HTTPS remote URL for a named repo entirely from Gitea config.
/// The local clone's .git/config remote URL is irrelevant — this is the authority.
/// </summary>
private string GetRemoteUrl(string repoName)
{
var baseUrl = (config["Gitea:BaseUrl"]
?? throw new InvalidOperationException("Gitea:BaseUrl is not configured.")).TrimEnd('/');
var owner = config[$"Gitea:Repos:{repoName}:Owner"] ?? config["Gitea:Owner"]
?? throw new InvalidOperationException($"Gitea owner not configured for '{repoName}'.");
var repoSlug = config[$"Gitea:Repos:{repoName}:Repo"] ?? repoName;
return $"{baseUrl}/{owner}/{repoSlug}.git";
}
/// <summary>
/// Returns the 'origin' remote after normalising its URL to the config-driven HTTPS URL.
/// If the clone was checked out with SSH (e.g. on a dev machine), this corrects it silently
/// so that LibGit2Sharp — which has no SSH support — always uses HTTPS.
/// </summary>
private Remote EnsureRemote(Repository repo, string repoName)
{
var url = GetRemoteUrl(repoName);
var remote = repo.Network.Remotes["origin"];
if (remote is null)
return repo.Network.Remotes.Add("origin", url);
if (remote.Url != url)
repo.Network.Remotes.Update("origin", r => r.Url = url);
return repo.Network.Remotes["origin"]!;
}
// ── 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 = EnsureRemote(repo, repoName);
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];
// Always read from the remote tracking ref so the status reflects what is on origin,
// not the server's potentially-stale local branch pointer.
var branch = repo.Branches[$"origin/{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[$"origin/{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, tip.Sha));
}
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 = EnsureRemote(repo, repoName);
var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList();
repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions());
// 2. Resolve branches — always read from origin/ so we reflect what is actually on the remote,
// never the server's potentially-stale local branch pointers.
var fromBranch = repo.Branches[$"origin/{from}"]
?? throw new InvalidOperationException($"Remote branch 'origin/{from}' not found.");
// `to` is read locally because we need to mutate its ref and push — it is immediately
// fast-forwarded to origin/{to} in the next step so it is never stale when used.
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 = EnsureRemote(repo, repoName);
// 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 = EnsureRemote(repo, repoName);
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 = EnsureRemote(repo, repoName);
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)
// Always read from origin/ tracking refs — never local branch pointers.
var branch = repo.Branches[$"origin/{branchName}"];
// ── Branch missing ──────────────────────────────────────────────
if (branch?.Tip is null)
{
var srcTip = srcName is not null ? repo.Branches[$"origin/{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[$"origin/{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 = EnsureRemote(repo, repoName);
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 []; }
}
// ── OPC number extraction ─────────────────────────────────────────────
private static readonly System.Text.RegularExpressions.Regex OpcTagPattern =
new(@"OPC\s*#\s*(\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase
| System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>
/// Scans the most recent <paramref name="limit"/> commits on <paramref name="branch"/> and
/// returns a distinct, sorted list of OPC numbers referenced in commit messages (e.g. "OPC # 0042").
/// Safe to call when git is not configured — returns an empty list on any error.
/// </summary>
public Task<List<string>> ExtractOpcNumbersAsync(
string repoName = "Clarity",
string branch = "main",
int limit = 50,
CancellationToken ct = default) =>
Task.Run(() => ExtractOpcNumbersCore(repoName, branch, limit), ct);
private List<string> ExtractOpcNumbersCore(string repoName, string branch, int limit)
{
var repoPath = GetRepoPath(repoName);
if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath))
return [];
try
{
using var repo = new Repository(repoPath);
var b = repo.Branches[branch] ?? repo.Branches[$"origin/{branch}"];
if (b is null) return [];
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var commit in b.Commits.Take(limit))
foreach (System.Text.RegularExpressions.Match m in OpcTagPattern.Matches(commit.Message))
set.Add($"OPC # {m.Groups[1].Value.PadLeft(4, '0')}");
return [.. set.OrderBy(x => x)];
}
catch (Exception ex)
{
logger.LogWarning(ex, "ExtractOpcNumbers failed for {Repo}/{Branch}", repoName, branch);
return [];
}
}
/// <summary>
/// Returns distinct, sorted OPC numbers for commits reachable from <paramref name="toSha"/>
/// that are NOT reachable from <paramref name="fromSha"/> — i.e. the exact delta for this release.
/// Falls back to <see cref="ExtractOpcNumbersAsync"/> (last 50 commits) when <paramref name="fromSha"/>
/// is null (first-ever release for this environment).
/// </summary>
public Task<List<string>> ExtractOpcNumbersDeltaAsync(
string repoName,
string toSha,
string? fromSha,
CancellationToken ct = default) =>
fromSha is null
? ExtractOpcNumbersAsync(repoName, ct: ct)
: Task.Run(() => ExtractOpcNumbersDeltaCore(repoName, toSha, fromSha), ct);
private List<string> ExtractOpcNumbersDeltaCore(string repoName, string toSha, string fromSha)
{
var repoPath = GetRepoPath(repoName);
if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath))
return [];
try
{
using var repo = new Repository(repoPath);
var toCommit = repo.Lookup<Commit>(toSha);
var fromCommit = repo.Lookup<Commit>(fromSha);
if (toCommit is null) return [];
var filter = fromCommit is null
? new CommitFilter { IncludeReachableFrom = toCommit }
: new CommitFilter { IncludeReachableFrom = toCommit, ExcludeReachableFrom = fromCommit };
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var commit in repo.Commits.QueryBy(filter))
foreach (System.Text.RegularExpressions.Match m in OpcTagPattern.Matches(commit.Message))
set.Add($"OPC # {m.Groups[1].Value.PadLeft(4, '0')}");
return [.. set.OrderBy(x => x)];
}
catch (Exception ex)
{
logger.LogWarning(ex, "ExtractOpcNumbersDelta failed for {Repo} {From}..{To}", repoName, fromSha[..7], toSha[..7]);
return [];
}
}
/// <summary>Returns the full HEAD SHA of <paramref name="branch"/> in <paramref name="repoName"/>, or null.</summary>
public string? GetBranchTipSha(string repoName, string branch)
{
var repoPath = GetRepoPath(repoName);
if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath)) return null;
try
{
using var repo = new Repository(repoPath);
return (repo.Branches[$"origin/{branch}"] ?? repo.Branches[branch])?.Tip?.Sha;
}
catch { return null; }
}
}
/// <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
string? TipSha = null // full 40-char SHA for build-gate checks
);