OPC # 0006: OPC Git Trunk-Based management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user