OPC # 0006: OPC Git Trunk-Based management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-26 14:30:10 -04:00
parent 5e969a2b3e
commit bb0c6e08c7
5 changed files with 114 additions and 10 deletions
+1
View File
@@ -166,6 +166,7 @@ await using (var cmd = ds.CreateCommand("""
// Idempotent column additions for schema migrations // Idempotent column additions for schema migrations
await using (var migCmd = ds.CreateCommand(""" 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 opc_numbers TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE release_record ADD COLUMN IF NOT EXISTS commit_sha VARCHAR(40);
""")) """))
await migCmd.ExecuteNonQueryAsync(); await migCmd.ExecuteNonQueryAsync();
@@ -787,6 +787,64 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
return []; 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> /// <summary>A single unreleased commit — carries full SHA for cherry-pick operations.</summary>
+16 -4
View File
@@ -51,7 +51,12 @@ public class ReleaseService(
return blocked; 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 try
{ {
@@ -183,9 +188,16 @@ public class ReleaseService(
} }
finally finally
{ {
// Stamp OPC ticket numbers from recent commits on the target branch. // Stamp the exact OPC ticket numbers introduced by this release:
var branch = targetEnv switch { "fdev" => "develop", "staging" => "staging", "uat" => "uat", _ => "main" }; // diff from previous release's SHA to this release's SHA on the Clarity branch.
try { record.OpcNumbers = await promotions.ExtractOpcNumbersAsync("Clarity", branch, 50, ct); } 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 */ } catch { /* git not configured — continue without OPC stamp */ }
await history.UpdateReleaseAsync(record); await history.UpdateReleaseAsync(record);
@@ -17,6 +17,7 @@ public class ReleaseRecord
public DateTimeOffset? FinishedAt { get; set; } public DateTimeOffset? FinishedAt { get; set; }
public List<TenantReleaseResult> Tenants { get; set; } = []; public List<TenantReleaseResult> Tenants { get; set; } = [];
public List<string> OpcNumbers { get; set; } = []; public List<string> OpcNumbers { get; set; } = [];
public string? CommitSha { get; set; } // Clarity branch HEAD SHA at release time
} }
public class TenantReleaseResult public class TenantReleaseResult
@@ -142,13 +142,13 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
// ── Releases ──────────────────────────────────────────────────────────── // ── Releases ────────────────────────────────────────────────────────────
public async Task<ReleaseRecord> CreateReleaseAsync(string environment, string imageName) public async Task<ReleaseRecord> 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(""" await using var cmd = db.CreateCommand("""
INSERT INTO release_record (id, environment, image_name, status, started_at, opc_numbers) INSERT INTO release_record (id, environment, image_name, status, started_at, opc_numbers, commit_sha)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
"""); """);
cmd.Parameters.AddWithValue(record.Id); cmd.Parameters.AddWithValue(record.Id);
cmd.Parameters.AddWithValue(record.Environment); cmd.Parameters.AddWithValue(record.Environment);
@@ -156,6 +156,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
cmd.Parameters.AddWithValue(record.Status.ToString()); cmd.Parameters.AddWithValue(record.Status.ToString());
cmd.Parameters.AddWithValue(record.StartedAt); cmd.Parameters.AddWithValue(record.StartedAt);
cmd.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. record.OpcNumbers] }); cmd.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. record.OpcNumbers] });
cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
return record; return record;
@@ -169,12 +170,13 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
await using var tx = await conn.BeginTransactionAsync(); await using var tx = await conn.BeginTransactionAsync();
await using var upd = new NpgsqlCommand(""" await using var upd = new NpgsqlCommand("""
UPDATE release_record SET status = $2, finished_at = $3, opc_numbers = $4 WHERE id = $1 UPDATE release_record SET status = $2, finished_at = $3, opc_numbers = $4, commit_sha = $5 WHERE id = $1
""", conn, tx); """, conn, tx);
upd.Parameters.AddWithValue(record.Id); upd.Parameters.AddWithValue(record.Id);
upd.Parameters.AddWithValue(record.Status.ToString()); upd.Parameters.AddWithValue(record.Status.ToString());
upd.Parameters.AddWithValue(record.FinishedAt!.Value); upd.Parameters.AddWithValue(record.FinishedAt!.Value);
upd.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. record.OpcNumbers] }); upd.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. record.OpcNumbers] });
upd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value);
await upd.ExecuteNonQueryAsync(); await upd.ExecuteNonQueryAsync();
// Replace tenant results wholesale on each update // Replace tenant results wholesale on each update
@@ -206,7 +208,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
var lookup = new Dictionary<string, ReleaseRecord>(); var lookup = new Dictionary<string, ReleaseRecord>();
await using var cmd = db.CreateCommand(""" 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 FROM release_record
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 50 LIMIT 50
@@ -224,6 +226,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
StartedAt = reader.GetFieldValue<DateTimeOffset>(4), StartedAt = reader.GetFieldValue<DateTimeOffset>(4),
FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue<string[]>(6)], OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue<string[]>(6)],
CommitSha = reader.IsDBNull(7) ? null : reader.GetString(7),
}; };
ordered.Add(r); ordered.Add(r);
lookup[r.Id] = r; lookup[r.Id] = r;
@@ -254,5 +257,34 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
return ordered; return ordered;
} }
/// <summary>
/// Returns the most recent succeeded release for <paramref name="environment"/>, or null if none exists.
/// Used to calculate the OPC ticket delta between releases (previousSha..currentSha).
/// </summary>
public async Task<ReleaseRecord?> 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<ReleaseStatus>(reader.GetString(3)),
StartedAt = reader.GetFieldValue<DateTimeOffset>(4),
FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue<string[]>(6)],
CommitSha = reader.IsDBNull(7) ? null : reader.GetString(7),
};
}
} }