diff --git a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs index 7084693..3107814 100644 --- a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs +++ b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs @@ -1,5 +1,6 @@ using ControlPlane.Api.Services; using ControlPlane.Core.Models; +using ControlPlane.Core.Services; using System.Text.Json; namespace ControlPlane.Api.Endpoints; @@ -178,6 +179,27 @@ public static class PromotionEndpoints } }); + // GET /api/promotions/build-gate?sha={sha} + // Returns the build-gate status for the given commit SHA. + // If status is "Red", the promote button in the UI should be disabled. + g.MapGet("/build-gate", async (string sha, BuildHistoryService history, CancellationToken ct) => + { + var builds = await history.GetBuildsByShaAsync(sha); + var latest = builds.MaxBy(b => b.StartedAt); + if (latest is null) + return Results.Ok(new { status = "Unknown", sha, buildId = (string?)null, buildStatus = (string?)null }); + + var gateStatus = latest.Status switch + { + BuildStatus.Succeeded => "Green", + BuildStatus.Failed => "Red", + BuildStatus.Running => "Running", + _ => "Unknown", + }; + + return Results.Ok(new { status = gateStatus, sha, buildId = latest.Id, buildStatus = latest.Status.ToString() }); + }); + return app; } } diff --git a/ControlPlane.Api/Program.cs b/ControlPlane.Api/Program.cs index 64f2572..462be6f 100644 --- a/ControlPlane.Api/Program.cs +++ b/ControlPlane.Api/Program.cs @@ -163,4 +163,10 @@ await using (var cmd = ds.CreateCommand(""" """)) await cmd.ExecuteNonQueryAsync(); +// 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 '{}'; + """)) + await migCmd.ExecuteNonQueryAsync(); + app.Run(); diff --git a/ControlPlane.Api/Services/PromotionService.cs b/ControlPlane.Api/Services/PromotionService.cs index cfa0fa7..aa00bbb 100644 --- a/ControlPlane.Api/Services/PromotionService.cs +++ b/ControlPlane.Api/Services/PromotionService.cs @@ -138,7 +138,7 @@ public class PromotionService(IConfiguration config, ILogger l } result.Add(new BranchStatus(branchName, true, tip.Sha[..7], summary, - ahead, behind, unreleasedCommits)); + ahead, behind, unreleasedCommits, tip.Sha)); } return result; @@ -716,6 +716,49 @@ public class PromotionService(IConfiguration config, ILogger l try { return JsonSerializer.Deserialize>(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); + + /// + /// Scans the most recent commits on 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. + /// + public Task> ExtractOpcNumbersAsync( + string repoName = "Clarity", + string branch = "main", + int limit = 50, + CancellationToken ct = default) => + Task.Run(() => ExtractOpcNumbersCore(repoName, branch, limit), ct); + + private List 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(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 []; + } + } } /// A single unreleased commit — carries full SHA for cherry-pick operations. @@ -729,5 +772,6 @@ public record BranchStatus( 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 + CommitInfo[] UnreleasedCommits, // rich commit objects for cherry-pick UI + string? TipSha = null // full 40-char SHA for build-gate checks ); diff --git a/ControlPlane.Api/Services/ReleaseService.cs b/ControlPlane.Api/Services/ReleaseService.cs index 93a5314..31d17d9 100644 --- a/ControlPlane.Api/Services/ReleaseService.cs +++ b/ControlPlane.Api/Services/ReleaseService.cs @@ -17,6 +17,7 @@ public class ReleaseService( IConfiguration config, TenantRegistryService registry, BuildHistoryService history, + PromotionService promotions, ILogger logger) { private static readonly SemaphoreSlim _lock = new(1, 1); @@ -182,6 +183,11 @@ 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); } + catch { /* git not configured — continue without OPC stamp */ } + await history.UpdateReleaseAsync(record); _lock.Release(); } diff --git a/ControlPlane.Core/Models/ReleaseRecord.cs b/ControlPlane.Core/Models/ReleaseRecord.cs index 67c892a..fe0cd2f 100644 --- a/ControlPlane.Core/Models/ReleaseRecord.cs +++ b/ControlPlane.Core/Models/ReleaseRecord.cs @@ -15,7 +15,8 @@ public class ReleaseRecord public ReleaseStatus Status { get; set; } = ReleaseStatus.Running; public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? FinishedAt { get; set; } - public List Tenants { get; set; } = []; + public List Tenants { get; set; } = []; + public List OpcNumbers { get; set; } = []; } public class TenantReleaseResult diff --git a/ControlPlane.Core/Services/BuildHistoryService.cs b/ControlPlane.Core/Services/BuildHistoryService.cs index a7dfb78..babeb53 100644 --- a/ControlPlane.Core/Services/BuildHistoryService.cs +++ b/ControlPlane.Core/Services/BuildHistoryService.cs @@ -104,6 +104,42 @@ public class BuildHistoryService(NpgsqlDataSource db, ILoggerReturns all build records whose commit_sha exactly matches . + public async Task> GetBuildsByShaAsync(string sha) + { + var result = new List(); + + await using var cmd = db.CreateCommand(""" + SELECT id, kind, target, status, started_at, finished_at, duration_ms, image_digest, commit_sha, log + FROM build_record + WHERE commit_sha = $1 + ORDER BY started_at DESC + """); + cmd.Parameters.AddWithValue(sha); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var logText = reader.IsDBNull(9) ? "" : reader.GetString(9); + result.Add(new BuildRecord + { + Id = reader.GetString(0), + Kind = Enum.Parse(reader.GetString(1)), + Target = reader.GetString(2), + Status = Enum.Parse(reader.GetString(3)), + StartedAt = reader.GetFieldValue(4), + FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), + DurationMs = reader.IsDBNull(6) ? null : reader.GetInt32(6), + ImageDigest = reader.IsDBNull(7) ? null : reader.GetString(7), + CommitSha = reader.IsDBNull(8) ? null : reader.GetString(8), + Log = logText.Length == 0 ? [] : [.. logText.Split('\n')], + }); + } + + return result; + } + // ── Releases ──────────────────────────────────────────────────────────── public async Task CreateReleaseAsync(string environment, string imageName) @@ -111,14 +147,15 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger { TypedValue = [.. record.OpcNumbers] }); await cmd.ExecuteNonQueryAsync(); return record; @@ -132,11 +169,12 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger { TypedValue = [.. record.OpcNumbers] }); await upd.ExecuteNonQueryAsync(); // Replace tenant results wholesale on each update @@ -168,7 +206,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger(); await using var cmd = db.CreateCommand(""" - SELECT id, environment, image_name, status, started_at, finished_at + SELECT id, environment, image_name, status, started_at, finished_at, opc_numbers FROM release_record ORDER BY started_at DESC LIMIT 50 @@ -185,6 +223,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger(reader.GetString(3)), StartedAt = reader.GetFieldValue(4), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), + OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue(6)], }; ordered.Add(r); lookup[r.Id] = r; diff --git a/clarity.controlplane/src/api/promotionApi.ts b/clarity.controlplane/src/api/promotionApi.ts index 7e4fabb..d8a7438 100644 --- a/clarity.controlplane/src/api/promotionApi.ts +++ b/clarity.controlplane/src/api/promotionApi.ts @@ -16,6 +16,7 @@ export interface BranchStatus { aheadOfNext: number; behindNext: number; unreleasedCommits: CommitInfo[]; + tipSha: string | null; } export interface PromotionRecord { @@ -202,3 +203,18 @@ export async function createLadderBranch(branch: string, fromSha: string, repo: throw new Error((body as { error?: string }).error ?? res.statusText); } } + +// ── Build gate ─────────────────────────────────────────────────────────────────────────────── + +export interface BuildGate { + status: 'Green' | 'Red' | 'Running' | 'Unknown'; + sha: string; + buildId: string | null; + buildStatus: string | null; +} + +export async function getBuildGate(sha: string): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/build-gate?sha=${encodeURIComponent(sha)}`); + if (!res.ok) throw new Error(`Failed to get build gate: ${res.statusText}`); + return res.json(); +} diff --git a/clarity.controlplane/src/api/releaseApi.ts b/clarity.controlplane/src/api/releaseApi.ts index c52c2fb..b57414b 100644 --- a/clarity.controlplane/src/api/releaseApi.ts +++ b/clarity.controlplane/src/api/releaseApi.ts @@ -15,6 +15,7 @@ export interface ReleaseRecord { startedAt: string; finishedAt?: string; tenants: TenantReleaseResult[]; + opcNumbers: string[]; } export async function getReleaseHistory(): Promise { diff --git a/clarity.controlplane/src/pages/BranchPage.tsx b/clarity.controlplane/src/pages/BranchPage.tsx index 5e54d60..6d73768 100644 --- a/clarity.controlplane/src/pages/BranchPage.tsx +++ b/clarity.controlplane/src/pages/BranchPage.tsx @@ -6,9 +6,9 @@ import { } from '@blueprintjs/core'; import { getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick, - resetBranch, getAllConformanceReports, createLadderBranch, + resetBranch, getAllConformanceReports, createLadderBranch, getBuildGate, type BranchStatus, type CommitInfo, type PromotionRecord, - type ConformanceReport, type BranchConformanceCheck, + type ConformanceReport, type BranchConformanceCheck, type BuildGate, } from '../api/promotionApi'; import { getImageBuildHistory, type BuildHistoryRecord } from '../api/imageApi'; @@ -37,6 +37,19 @@ const BUILD_COLOR: Record = { Running: '#2d72d2', }; +// -- OPC tag helper ----------------------------------------------------------- + +const OPC_TAG_RE = /OPC\s*#\s*(\d+)/gi; +function extractOpcTags(message: string): string[] { + const tags: string[] = []; + let m: RegExpExecArray | null; + // Reset lastIndex before every use (global flag retains state) + OPC_TAG_RE.lastIndex = 0; + while ((m = OPC_TAG_RE.exec(message)) !== null) + tags.push(`OPC # ${m[1].padStart(4, '0')}`); + return [...new Set(tags)]; +} + // -- BuildSparkline ----------------------------------------------------------- const MAX_BAR_H = 44; @@ -120,19 +133,33 @@ function PromotionTerminal({ lines }: { lines: string[] }) { // -- PromoteDialog ------------------------------------------------------------ function PromoteDialog({ - from, to, repo, onClose, onDone, + from, to, repo, fromSha, onClose, onDone, }: { - from: string; to: string; repo: RepoName; - onClose: () => void; - onDone: () => void; + from: string; + to: string; + repo: RepoName; + fromSha?: string | null; + onClose: () => void; + onDone: () => void; }) { - const [note, setNote] = useState(''); - const [running, setRunning] = useState(false); - const [logs, setLogs] = useState([]); - const [done, setDone] = useState(false); - const [error, setError] = useState(null); + const [note, setNote] = useState(''); + const [running, setRunning] = useState(false); + const [logs, setLogs] = useState([]); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [gate, setGate] = useState(null); + const [gateLoading, setGateLoading] = useState(false); const cancelRef = useRef<(() => void) | null>(null); + useEffect(() => { + if (!fromSha) return; + setGateLoading(true); + getBuildGate(fromSha) + .then(setGate) + .catch(() => {}) + .finally(() => setGateLoading(false)); + }, [fromSha]); + const fromMeta = LADDER.find((l) => l.branch === from); const toMeta = LADDER.find((l) => l.branch === to); @@ -171,6 +198,30 @@ function PromoteDialog({

Merges {from} into {to} via no-fast-forward and pushes to origin.

+ {fromSha && ( +
+ Build gate: + {gateLoading ? ( + + ) : gate ? ( + + {gate.status === 'Green' ? 'Passed' : gate.status === 'Red' ? 'Build failed' : gate.status} + + ) : null} + {gate?.buildId && ( + {gate.buildId} + )} +
+ )} + {gate?.status === 'Red' && ( + + The build for this commit failed. Fix it before promoting to prevent broken code reaching {to}. + + )}