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
@@ -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>