OPC # 0006: OPC Git Trunk-Based management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-26 11:54:24 -04:00
parent 553ea59d39
commit 79c69e1363
10 changed files with 252 additions and 33 deletions
@@ -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;
}
}
+6
View File
@@ -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();
+46 -2
View File
@@ -138,7 +138,7 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> 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<PromotionService> l
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>A single unreleased commit — carries full SHA for cherry-pick operations.</summary>
@@ -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
);
@@ -17,6 +17,7 @@ public class ReleaseService(
IConfiguration config,
TenantRegistryService registry,
BuildHistoryService history,
PromotionService promotions,
ILogger<ReleaseService> 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();
}
+2 -1
View File
@@ -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<TenantReleaseResult> Tenants { get; set; } = [];
public List<TenantReleaseResult> Tenants { get; set; } = [];
public List<string> OpcNumbers { get; set; } = [];
}
public class TenantReleaseResult
@@ -104,6 +104,42 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
return result;
}
// ── Builds by SHA ────────────────────────────────────────────────────────
/// <summary>Returns all build records whose <c>commit_sha</c> exactly matches <paramref name="sha"/>.</summary>
public async Task<List<BuildRecord>> GetBuildsByShaAsync(string sha)
{
var result = new List<BuildRecord>();
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<BuildKind>(reader.GetString(1)),
Target = reader.GetString(2),
Status = Enum.Parse<BuildStatus>(reader.GetString(3)),
StartedAt = reader.GetFieldValue<DateTimeOffset>(4),
FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(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<ReleaseRecord> CreateReleaseAsync(string environment, string imageName)
@@ -111,14 +147,15 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
var record = new ReleaseRecord { Environment = environment, ImageName = imageName };
await using var cmd = db.CreateCommand("""
INSERT INTO release_record (id, environment, image_name, status, started_at)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO release_record (id, environment, image_name, status, started_at, opc_numbers)
VALUES ($1, $2, $3, $4, $5, $6)
""");
cmd.Parameters.AddWithValue(record.Id);
cmd.Parameters.AddWithValue(record.Environment);
cmd.Parameters.AddWithValue(record.ImageName);
cmd.Parameters.AddWithValue(record.Status.ToString());
cmd.Parameters.AddWithValue(record.StartedAt);
cmd.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. record.OpcNumbers] });
await cmd.ExecuteNonQueryAsync();
return record;
@@ -132,11 +169,12 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
await using var tx = await conn.BeginTransactionAsync();
await using var upd = new NpgsqlCommand("""
UPDATE release_record SET status = $2, finished_at = $3 WHERE id = $1
UPDATE release_record SET status = $2, finished_at = $3, opc_numbers = $4 WHERE id = $1
""", conn, tx);
upd.Parameters.AddWithValue(record.Id);
upd.Parameters.AddWithValue(record.Status.ToString());
upd.Parameters.AddWithValue(record.FinishedAt!.Value);
upd.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. record.OpcNumbers] });
await upd.ExecuteNonQueryAsync();
// Replace tenant results wholesale on each update
@@ -168,7 +206,7 @@ public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryServic
var lookup = new Dictionary<string, ReleaseRecord>();
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<BuildHistoryServic
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)],
};
ordered.Add(r);
lookup[r.Id] = r;
@@ -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<BuildGate> {
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();
}
@@ -15,6 +15,7 @@ export interface ReleaseRecord {
startedAt: string;
finishedAt?: string;
tenants: TenantReleaseResult[];
opcNumbers: string[];
}
export async function getReleaseHistory(): Promise<ReleaseRecord[]> {
+102 -26
View File
@@ -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<string, string> = {
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<string[]>([]);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState('');
const [running, setRunning] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [gate, setGate] = useState<BuildGate | null>(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({
<p style={{ marginBottom: '0.75rem', color: '#738091', fontSize: '0.85rem' }}>
Merges <code>{from}</code> into <code>{to}</code> via no-fast-forward and pushes to origin.
</p>
{fromSha && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.6rem' }}>
<span style={{ fontSize: '0.82rem', color: '#738091' }}>Build gate:</span>
{gateLoading ? (
<Spinner size={12} />
) : gate ? (
<Tag
intent={gate.status === 'Green' ? Intent.SUCCESS : gate.status === 'Red' ? Intent.DANGER : Intent.WARNING}
minimal
icon={gate.status === 'Green' ? 'tick-circle' : gate.status === 'Red' ? 'error' : 'warning-sign' as any}
>
{gate.status === 'Green' ? 'Passed' : gate.status === 'Red' ? 'Build failed' : gate.status}
</Tag>
) : null}
{gate?.buildId && (
<code style={{ fontSize: '0.72rem', color: '#8f99a8' }}>{gate.buildId}</code>
)}
</div>
)}
{gate?.status === 'Red' && (
<Callout intent={Intent.DANGER} icon="error" style={{ marginBottom: '0.75rem', fontSize: '0.82rem' }}>
The build for this commit failed. Fix it before promoting to prevent broken code reaching <strong>{to}</strong>.
</Callout>
)}
<TextArea
fill
rows={3}
@@ -207,7 +258,7 @@ function PromoteDialog({
icon="arrow-right"
text={running ? 'Promoting\u2026' : `Promote ${from} \u2192 ${to}`}
loading={running}
disabled={running}
disabled={running || gate?.status === 'Red'}
onClick={handlePromote}
/>
</>
@@ -286,9 +337,10 @@ function ResetDialog({
// -- CherryPickDialog ---------------------------------------------------------
function CherryPickDialog({
shas, from, to, repo, onClose, onDone,
shas, commits, from, to, repo, onClose, onDone,
}: {
shas: string[];
commits: CommitInfo[];
from: string;
to: string;
repo: RepoName;
@@ -336,11 +388,28 @@ function CherryPickDialog({
>
<DialogBody>
{!running && !done && (
<p style={{ marginBottom: '0.75rem', color: '#738091', fontSize: '0.85rem' }}>
Applies <strong>{shas.length}</strong> selected commit{shas.length > 1 ? 's' : ''} as new
commits on <code>{to}</code>. The branches will diverge use the Reset button to
re-align if a full fast-forward promote is needed later.
</p>
<>
<p style={{ marginBottom: '0.5rem', color: '#738091', fontSize: '0.85rem' }}>
Applies <strong>{shas.length}</strong> selected commit{shas.length > 1 ? 's' : ''} as new
commits on <code>{to}</code>. The branches will diverge use the Reset button to
re-align if a full fast-forward promote is needed later.
</p>
{commits.length > 0 && (
<div style={{ background: '#f6f7f9', border: '1px solid #e5e8eb', borderRadius: 4, padding: '0.4rem 0.6rem', fontSize: '0.74rem', marginBottom: '0.25rem' }}>
{commits.map(c => (
<div key={c.sha} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.1rem' }}>
<code style={{ color: '#2d72d2', flexShrink: 0 }}>{c.shortSha}</code>
{extractOpcTags(c.message).map(tag => (
<Tag key={tag} intent={Intent.PRIMARY} minimal round style={{ fontSize: '0.65rem', flexShrink: 0, padding: '0 4px' }}>
{tag}
</Tag>
))}
<span style={{ color: '#1c2127', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{c.message}</span>
</div>
))}
</div>
)}
</>
)}
{(running || logs.length > 0) && (
<div style={{ marginTop: '0.75rem' }}>
@@ -390,9 +459,9 @@ function LadderStep({
meta: typeof LADDER[number];
nextBranch: string | null;
isLast: boolean;
onPromote: (from: string, to: string) => void;
onPromote: (from: string, to: string, tipSha: string | null) => void;
onReset: (branch: string, toSha: string) => void;
onCherryPick: (shas: string[], from: string, to: string) => void;
onCherryPick: (shas: string[], commits: CommitInfo[], from: string, to: string) => void;
builds?: BuildHistoryRecord[];
}) {
const [open, setOpen] = useState(false);
@@ -479,7 +548,7 @@ function LadderStep({
icon="arrow-right"
text={`Promote \u2192 ${nextBranch}`}
disabled={(status?.aheadOfNext ?? 0) === 0}
onClick={() => onPromote(meta.branch, nextBranch)}
onClick={() => onPromote(meta.branch, nextBranch, status?.tipSha ?? null)}
/>
</div>
)}
@@ -523,6 +592,11 @@ function LadderStep({
style={{ margin: 0 }}
/>
<code style={{ color: '#2d72d2', flexShrink: 0 }}>{c.shortSha}</code>
{extractOpcTags(c.message).map(tag => (
<Tag key={tag} intent={Intent.PRIMARY} minimal round style={{ fontSize: '0.65rem', flexShrink: 0, padding: '0 4px' }}>
{tag}
</Tag>
))}
<span style={{ color: '#1c2127', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.message}
</span>
@@ -537,7 +611,7 @@ function LadderStep({
small
icon="git-merge"
text={`Cherry-pick ${selectedShas.size} \u2192 ${nextBranch}`}
onClick={() => onCherryPick([...selectedShas], meta.branch, nextBranch)}
onClick={() => onCherryPick([...selectedShas], (status?.unreleasedCommits ?? []).filter(c => selectedShas.has(c.sha)), meta.branch, nextBranch)}
/>
</div>
)}
@@ -967,9 +1041,9 @@ export default function BranchPage() {
const [builds, setBuilds] = useState<BuildHistoryRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ from: string; to: string } | null>(null);
const [dialog, setDialog] = useState<{ from: string; to: string; tipSha: string | null } | null>(null);
const [resetDialog, setResetDialog] = useState<{ branch: string; toSha: string } | null>(null);
const [cherryPickDialog, setCherryPickDialog] = useState<{ shas: string[]; from: string; to: string } | null>(null);
const [cherryPickDialog, setCherryPickDialog] = useState<{ shas: string[]; commits: CommitInfo[]; from: string; to: string } | null>(null);
const load = async (r: RepoName = repo) => {
setLoading(true);
@@ -1073,9 +1147,9 @@ export default function BranchPage() {
meta={meta}
nextBranch={nextBranch}
isLast={isLast}
onPromote={(from, to) => setDialog({ from, to })}
onPromote={(from, to, tipSha) => setDialog({ from, to, tipSha })}
onReset={(branch, toSha) => setResetDialog({ branch, toSha })}
onCherryPick={(shas, from, to) => setCherryPickDialog({ shas, from, to })}
onCherryPick={(shas, commits, from, to) => setCherryPickDialog({ shas, commits, from, to })}
builds={meta.branch === 'develop' ? builds : undefined}
/>
{!isLast && (
@@ -1121,6 +1195,7 @@ export default function BranchPage() {
from={dialog.from}
to={dialog.to}
repo={repo}
fromSha={dialog.tipSha}
onClose={() => setDialog(null)}
onDone={() => { setDialog(null); load(); }}
/>
@@ -1146,6 +1221,7 @@ export default function BranchPage() {
{cherryPickDialog && (
<CherryPickDialog
shas={cherryPickDialog.shas}
commits={cherryPickDialog.commits}
from={cherryPickDialog.from}
to={cherryPickDialog.to}
repo={repo}
@@ -143,6 +143,14 @@ function ReleaseHistoryTable({ records }: { records: ReleaseRecord[] }) {
{expanded === r.id && (
<tr key={r.id + '-detail'}>
<td colSpan={7} style={{ padding: '0.4rem 1rem 0.8rem' }}>
{r.opcNumbers?.length > 0 && (
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.5rem', paddingBottom: '0.5rem', borderBottom: '1px solid #e5e8eb' }}>
<span style={{ fontSize: '0.72rem', color: '#8f99a8' }}>OPCs in this release:</span>
{r.opcNumbers.map(n => (
<Tag key={n} intent={Intent.PRIMARY} minimal round style={{ fontFamily: 'monospace', fontSize: '0.72rem' }}>{n}</Tag>
))}
</div>
)}
{r.tenants.map((t) => (
<div key={t.subdomain} style={{ display: 'flex', gap: '0.5rem', marginBottom: 2 }}>
<Tag intent={t.success ? Intent.SUCCESS : Intent.DANGER} minimal round>