From bb0c6e08c758901e71d772de3aa06b6afe3f468f Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sun, 26 Apr 2026 14:30:10 -0400 Subject: [PATCH] OPC # 0006: OPC Git Trunk-Based management Co-authored-by: Copilot --- ControlPlane.Api/Program.cs | 1 + ControlPlane.Api/Services/PromotionService.cs | 58 +++++++++++++++++++ ControlPlane.Api/Services/ReleaseService.cs | 20 +++++-- ControlPlane.Core/Models/ReleaseRecord.cs | 1 + .../Services/BuildHistoryService.cs | 44 ++++++++++++-- 5 files changed, 114 insertions(+), 10 deletions(-) diff --git a/ControlPlane.Api/Program.cs b/ControlPlane.Api/Program.cs index 462be6f..1e016fa 100644 --- a/ControlPlane.Api/Program.cs +++ b/ControlPlane.Api/Program.cs @@ -166,6 +166,7 @@ await using (var cmd = ds.CreateCommand(""" // Idempotent column additions for schema migrations await using (var migCmd = ds.CreateCommand(""" ALTER TABLE release_record ADD COLUMN IF NOT EXISTS opc_numbers TEXT[] NOT NULL DEFAULT '{}'; + ALTER TABLE release_record ADD COLUMN IF NOT EXISTS commit_sha VARCHAR(40); """)) await migCmd.ExecuteNonQueryAsync(); diff --git a/ControlPlane.Api/Services/PromotionService.cs b/ControlPlane.Api/Services/PromotionService.cs index a65bf50..044d2ed 100644 --- a/ControlPlane.Api/Services/PromotionService.cs +++ b/ControlPlane.Api/Services/PromotionService.cs @@ -787,6 +787,64 @@ public class PromotionService(IConfiguration config, ILogger l return []; } } + + /// + /// Returns distinct, sorted OPC numbers for commits reachable from + /// that are NOT reachable from — i.e. the exact delta for this release. + /// Falls back to (last 50 commits) when + /// is null (first-ever release for this environment). + /// + public Task> 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 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(toSha); + var fromCommit = repo.Lookup(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(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 []; + } + } + + /// Returns the full HEAD SHA of in , or null. + 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; } + } } /// A single unreleased commit — carries full SHA for cherry-pick operations. diff --git a/ControlPlane.Api/Services/ReleaseService.cs b/ControlPlane.Api/Services/ReleaseService.cs index 31d17d9..3b655cd 100644 --- a/ControlPlane.Api/Services/ReleaseService.cs +++ b/ControlPlane.Api/Services/ReleaseService.cs @@ -51,7 +51,12 @@ public class ReleaseService( return blocked; } - var record = await history.CreateReleaseAsync(targetEnv, ImageName); + // Resolve the Clarity branch for this environment and stamp the HEAD SHA + // before creating the record so we capture "what was deployed" accurately. + var branch = targetEnv switch { "fdev" => "develop", "staging" => "staging", "uat" => "uat", _ => "main" }; + var currentSha = promotions.GetBranchTipSha("Clarity", branch); + + var record = await history.CreateReleaseAsync(targetEnv, ImageName, currentSha); try { @@ -183,9 +188,16 @@ public class ReleaseService( } finally { - // Stamp OPC ticket numbers from recent commits on the target branch. - var branch = targetEnv switch { "fdev" => "develop", "staging" => "staging", "uat" => "uat", _ => "main" }; - try { record.OpcNumbers = await promotions.ExtractOpcNumbersAsync("Clarity", branch, 50, ct); } + // Stamp the exact OPC ticket numbers introduced by this release: + // diff from previous release's SHA to this release's SHA on the Clarity branch. + try + { + var prev = await history.GetLastSuccessfulReleaseForEnvAsync(targetEnv); + // Exclude the current (in-flight) record — it's not succeeded yet + var prevSha = prev?.Id == record.Id ? null : prev?.CommitSha; + if (currentSha is not null) + record.OpcNumbers = await promotions.ExtractOpcNumbersDeltaAsync("Clarity", currentSha, prevSha, ct); + } catch { /* git not configured — continue without OPC stamp */ } await history.UpdateReleaseAsync(record); diff --git a/ControlPlane.Core/Models/ReleaseRecord.cs b/ControlPlane.Core/Models/ReleaseRecord.cs index fe0cd2f..8b644f5 100644 --- a/ControlPlane.Core/Models/ReleaseRecord.cs +++ b/ControlPlane.Core/Models/ReleaseRecord.cs @@ -17,6 +17,7 @@ public class ReleaseRecord public DateTimeOffset? FinishedAt { get; set; } public List Tenants { get; set; } = []; public List OpcNumbers { get; set; } = []; + public string? CommitSha { get; set; } // Clarity branch HEAD SHA at release time } public class TenantReleaseResult diff --git a/ControlPlane.Core/Services/BuildHistoryService.cs b/ControlPlane.Core/Services/BuildHistoryService.cs index babeb53..03ae892 100644 --- a/ControlPlane.Core/Services/BuildHistoryService.cs +++ b/ControlPlane.Core/Services/BuildHistoryService.cs @@ -142,13 +142,13 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger CreateReleaseAsync(string environment, string imageName) + public async Task CreateReleaseAsync(string environment, string imageName, string? commitSha = null) { - var record = new ReleaseRecord { Environment = environment, ImageName = imageName }; + var record = new ReleaseRecord { Environment = environment, ImageName = imageName, CommitSha = commitSha }; await using var cmd = db.CreateCommand(""" - INSERT INTO release_record (id, environment, image_name, status, started_at, opc_numbers) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO release_record (id, environment, image_name, status, started_at, opc_numbers, commit_sha) + VALUES ($1, $2, $3, $4, $5, $6, $7) """); cmd.Parameters.AddWithValue(record.Id); cmd.Parameters.AddWithValue(record.Environment); @@ -156,6 +156,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger { TypedValue = [.. record.OpcNumbers] }); + cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(); return record; @@ -169,12 +170,13 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger { TypedValue = [.. record.OpcNumbers] }); + upd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value); await upd.ExecuteNonQueryAsync(); // Replace tenant results wholesale on each update @@ -206,7 +208,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger(); await using var cmd = db.CreateCommand(""" - SELECT id, environment, image_name, status, started_at, finished_at, opc_numbers + SELECT id, environment, image_name, status, started_at, finished_at, opc_numbers, commit_sha FROM release_record ORDER BY started_at DESC LIMIT 50 @@ -224,6 +226,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger(4), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue(6)], + CommitSha = reader.IsDBNull(7) ? null : reader.GetString(7), }; ordered.Add(r); lookup[r.Id] = r; @@ -254,5 +257,34 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger + /// Returns the most recent succeeded release for , or null if none exists. + /// Used to calculate the OPC ticket delta between releases (previousSha..currentSha). + /// + public async Task GetLastSuccessfulReleaseForEnvAsync(string environment) + { + await using var cmd = db.CreateCommand(""" + SELECT id, environment, image_name, status, started_at, finished_at, opc_numbers, commit_sha + FROM release_record + WHERE environment = $1 AND status = 'Succeeded' + ORDER BY started_at DESC + LIMIT 1 + """); + cmd.Parameters.AddWithValue(environment); + await using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return null; + return new ReleaseRecord + { + Id = reader.GetString(0), + Environment = reader.GetString(1), + ImageName = reader.GetString(2), + Status = Enum.Parse(reader.GetString(3)), + StartedAt = reader.GetFieldValue(4), + FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), + OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue(6)], + CommitSha = reader.IsDBNull(7) ? null : reader.GetString(7), + }; + } }