OPC # 0001: Extract OPC into standalone repo

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-25 19:17:48 -04:00
parent 7561ac7530
commit 76962a6af4
6 changed files with 326 additions and 65 deletions
+44 -3
View File
@@ -1,4 +1,45 @@
# Copilot Instructions # OPC (ControlPlane) — Copilot Instructions
## Project Guidelines ## What is OPC?
- Commit message format for OPC repo is: "OPC # XXXX: Description" (with a space between OPC and #, and space after #). Example: "OPC # 0001: Extract OPC into standalone repo". This convention comes from enterprise work jargon. OPC is the ClarityStack control plane. It provisions and manages tenant infrastructure.
"OPC" and "ControlPlane" are interchangeable — use context to determine meaning (system vs ticket).
"OPC # XXXX" refers to a ticket/work-item. Commit format: `OPC # XXXX: Description` (space between OPC and #, space after #).
## Responsibility
When a new tenant is onboarded, OPC orchestrates:
1. Keycloak realm + client creation
2. Vault secret engine + policies for the tenant
3. Gitea organisation + repo creation
4. Postgres database provisioning
5. Nginx config generation (written to `infra/nginx/conf.d/`)
6. Spinning up the tenant's Clarity Docker stack
## Projects
| Project | Role |
|---------|------|
| `ControlPlane.AppHost` | .NET Aspire host — owns opc-postgres, RabbitMQ, Gitea, ControlPlane UI |
| `ControlPlane.Api` | REST API — receives provisioning requests, publishes MassTransit messages |
| `ControlPlane.Worker` | Background worker — consumes MassTransit messages, executes provisioning steps |
| `ControlPlane.Core` | Shared models, interfaces, messages, config |
| `ControlPlane.ServiceDefaults` | Shared Aspire service defaults (OTel, resilience, service discovery) |
## Messaging
- MassTransit over RabbitMQ for async provisioning steps
- Api publishes, Worker consumes
## Key External Dependencies (via `infra/docker-compose.yml`)
- Keycloak → `http://localhost:8080`
- Vault → `http://localhost:8200`
- MinIO → `http://localhost:9000`
- Platform Postgres → `localhost:5432`
## OPC-owned Infrastructure (via Aspire)
- `opc-postgres` on port `5433` — databases: `opcdb`, `giteadb`
- RabbitMQ with management plugin
- Gitea at `http://opc.clarity.test`
## Conventions
- Target framework: .NET 10
- Nullable and ImplicitUsings enabled globally via root `Directory.Build.props`
- Central package management via root `Directory.Packages.props`
- Background services extend `BackgroundService`
+81 -27
View File
@@ -14,37 +14,65 @@ public static class GitEndpoints
return app; return app;
} }
// GET /api/git/log?grep=OPC+%23+0001&limit=50 // GET /api/git/log?grep=OPC+%23+0001&limit=50&repo=all
// repo can be "all" (default) or a named key from Git:Repos (e.g. "Clarity", "OPC", "Gateway").
// All responses include a repoKey field so the caller knows which repo each commit came from.
private static IResult GetLog( private static IResult GetLog(
IConfiguration config, IConfiguration config,
string? grep = null, string? grep = null,
int limit = 50) int limit = 50,
string repo = "all")
{ {
var repoPath = ResolveRepo(config); var repos = repo == "all"
if (repoPath is null) ? ResolveAllRepos(config)
return Results.Problem("Could not locate a git repository. Set Git:RepoRoot in appsettings."); : ResolveNamedRepo(config, repo) is { } p
? new Dictionary<string, string> { [repo] = p }
: null;
using var repo = new Repository(repoPath); if (repos is null || repos.Count == 0)
return Results.Problem("Could not locate any git repositories. Set Git:Repos in appsettings.");
var tips = repo.Branches var bucket = new List<(DateTimeOffset When, string RepoKey, GitCommit Commit)>();
.Where(b => b.Tip != null)
.Select(b => (GitObject)b.Tip)
.ToList();
var filter = new CommitFilter foreach (var (repoKey, repoPath) in repos)
{ {
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, if (!Directory.Exists(repoPath)) continue;
IncludeReachableFrom = tips.Count > 0 ? tips : (object)repo.Head,
};
IEnumerable<Commit> query = repo.Commits.QueryBy(filter); using var r = new Repository(repoPath);
if (!string.IsNullOrWhiteSpace(grep)) var tips = r.Branches
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); .Where(b => b.Tip != null)
.Select(b => (GitObject)b.Tip)
.ToList();
var commits = query var filter = new CommitFilter
{
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
IncludeReachableFrom = tips.Count > 0 ? tips : (object)r.Head,
};
IEnumerable<Commit> query = r.Commits.QueryBy(filter);
if (!string.IsNullOrWhiteSpace(grep))
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
foreach (var c in query.Take(limit))
bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c)));
}
var commits = bucket
.OrderByDescending(x => x.When)
.Take(limit) .Take(limit)
.Select(c => ToGitCommit(repo, c)) .Select(x => new
{
repoKey = x.RepoKey,
hash = x.Commit.Hash,
shortHash = x.Commit.ShortHash,
author = x.Commit.Author,
date = x.Commit.Date,
subject = x.Commit.Subject,
files = x.Commit.Files,
})
.ToList(); .ToList();
return Results.Ok(commits); return Results.Ok(commits);
@@ -114,36 +142,39 @@ public static class GitEndpoints
return Results.Ok(branches); return Results.Ok(branches);
} }
// GET /api/git/branch-coverage?commits=hash1,hash2,hash3 // GET /api/git/branch-coverage?commits=hash1,hash2,hash3&repo=OPC
// Returns each local branch and whether it contains ALL of the given commits. // repo defaults to the single configured repo. Pass a named key from Git:Repos to
private static IResult GetBranchCoverage(IConfiguration config, string? commits = null) // query a specific repo — the backend silently ignores hashes it cannot find.
private static IResult GetBranchCoverage(IConfiguration config, string? commits = null, string? repo = null)
{ {
if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty<object>()); if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty<object>());
var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (hashes.Length == 0) return Results.Ok(Array.Empty<object>()); if (hashes.Length == 0) return Results.Ok(Array.Empty<object>());
var repoPath = ResolveRepo(config); var repoPath = repo is not null
? ResolveNamedRepo(config, repo) ?? ResolveRepo(config)
: ResolveRepo(config);
if (repoPath is null) if (repoPath is null)
return Results.Problem("Could not locate a git repository."); return Results.Problem("Could not locate a git repository.");
using var repo = new Repository(repoPath); using var r = new Repository(repoPath);
var targetCommits = hashes var targetCommits = hashes
.Select(h => repo.Lookup<Commit>(h)) .Select(h => r.Lookup<Commit>(h))
.Where(c => c is not null) .Where(c => c is not null)
.ToList(); .ToList();
if (targetCommits.Count == 0) return Results.Ok(Array.Empty<object>()); if (targetCommits.Count == 0) return Results.Ok(Array.Empty<object>());
var result = repo.Branches var result = r.Branches
.Where(b => !b.IsRemote && b.Tip != null) .Where(b => !b.IsRemote && b.Tip != null)
.Select(b => .Select(b =>
{ {
var contains = targetCommits.All(tc => var contains = targetCommits.All(tc =>
{ {
// If merge base of branch tip and target == target, then target is an ancestor // If merge base of branch tip and target == target, then target is an ancestor
var mergeBase = repo.ObjectDatabase.FindMergeBase(b.Tip, tc!); var mergeBase = r.ObjectDatabase.FindMergeBase(b.Tip, tc!);
return mergeBase?.Sha == tc!.Sha; return mergeBase?.Sha == tc!.Sha;
}); });
return new return new
@@ -162,6 +193,29 @@ public static class GitEndpoints
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
/// Resolves a named repo from the Git:Repos registry.
private static string? ResolveNamedRepo(IConfiguration config, string repoKey)
{
var path = config[$"Git:Repos:{repoKey}"];
return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path) ? path : null;
}
/// Returns all repos from the Git:Repos registry that exist on disk.
/// Falls back to the single configured repo if the registry is empty.
private static IReadOnlyDictionary<string, string> ResolveAllRepos(IConfiguration config)
{
var result = new Dictionary<string, string>();
foreach (var child in config.GetSection("Git:Repos").GetChildren())
{
var path = child.Value;
if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
result[child.Key] = path;
}
if (result.Count == 0 && ResolveRepo(config) is { } single)
result["default"] = single;
return result;
}
/// Resolves the repo root: explicit config overrides, otherwise auto-discover /// Resolves the repo root: explicit config overrides, otherwise auto-discover
/// from the running assembly directory upward via LibGit2Sharp. /// from the running assembly directory upward via LibGit2Sharp.
private static string? ResolveRepo(IConfiguration config) private static string? ResolveRepo(IConfiguration config)
+9 -4
View File
@@ -10,12 +10,17 @@
"ApiKey": "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" "ApiKey": "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"
}, },
"Git": { "Git": {
"RepoRoot": "C:\\Users\\amadzarak\\source\\repos\\Clarity" "RepoRoot": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC",
"Repos": {
"Clarity": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\Clarity",
"OPC": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC",
"Gateway": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\gateway"
}
}, },
"Gitea": { "Gitea": {
"BaseUrl": "https://opc.clarity.test", "BaseUrl": "https://opc.clarity.test",
"Owner": "Clarity", "Owner": "ClarityStack",
"Repo": "Clarity", "Repo": "OPC",
"Token": "2ef325f682915c5959bf6a0dc73cec7034fcd2a2" "Token": "fcf9f66415754fb639a8343e3904e06b1d78c646"
} }
} }
+14 -2
View File
@@ -114,6 +114,7 @@ export async function deleteArtifact(artifactId: string): Promise<void> {
// The git log endpoint supports ?grep=OPC+%230001 to filter. // The git log endpoint supports ?grep=OPC+%230001 to filter.
export interface LinkedCommit { export interface LinkedCommit {
repoKey: string;
hash: string; hash: string;
shortHash: string; shortHash: string;
author: string; author: string;
@@ -123,9 +124,9 @@ export interface LinkedCommit {
} }
export async function getLinkedCommits(opcNumber: string): Promise<LinkedCommit[]> { export async function getLinkedCommits(opcNumber: string): Promise<LinkedCommit[]> {
const res = await fetch(`${BASE_URL}/api/git/log?grep=${encodeURIComponent(opcNumber)}&limit=50`); const res = await fetch(`${BASE_URL}/api/git/log?grep=${encodeURIComponent(opcNumber)}&limit=50&repo=all`);
if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`);
return res.json(); return res.json(); // backend now returns { repoKey, hash, shortHash, author, date, subject, files }
} }
// ── Pinned commits ──────────────────────────────────────────────────────────── // ── Pinned commits ────────────────────────────────────────────────────────────
@@ -192,6 +193,17 @@ export async function getBranchCoverage(hashes: string[]): Promise<BranchCoverag
return res.json(); return res.json();
} }
// Query branch coverage against a specific repo from the Git:Repos registry.
// The backend ignores hashes it cannot resolve, so passing cross-repo hashes is safe.
export async function getBranchCoverageForRepo(repoKey: string, hashes: string[]): Promise<BranchCoverage[]> {
if (hashes.length === 0) return [];
const res = await fetch(
`${BASE_URL}/api/git/branch-coverage?commits=${hashes.join(',')}&repo=${encodeURIComponent(repoKey)}`,
);
if (!res.ok) throw new Error(`Failed to get branch coverage for ${repoKey}: ${res.statusText}`);
return res.json();
}
// ── Commit detail (full diff) ───────────────────────────────────────────────── // ── Commit detail (full diff) ─────────────────────────────────────────────────
export interface CommitFile { export interface CommitFile {
+37
View File
@@ -758,6 +758,43 @@ body {
gap: 0.4rem; gap: 0.4rem;
} }
/* SDLC Delivery Chain */
.opc-delivery-chain {
background: #f6f7f9;
border: 1px solid #dce0e6;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.opc-sdlc-pipeline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.2rem;
margin-bottom: 0.35rem;
}
.opc-sdlc-stage-item {
display: flex;
align-items: center;
gap: 0.2rem;
}
.opc-sdlc-arrow {
color: #8f99a8;
font-size: 0.8rem;
font-weight: 600;
margin: 0 0.1rem;
user-select: none;
}
.opc-sdlc-furthest {
font-size: 0.75rem;
color: #738091;
margin-top: 0.3rem;
}
/* Commits section labels */ /* Commits section labels */
.opc-commits-section-label { .opc-commits-section-label {
display: flex; display: flex;
+141 -29
View File
@@ -11,7 +11,7 @@ import {
listOpcs, createOpc, updateOpc, getNextNumber, listOpcs, createOpc, updateOpc, getNextNumber,
listNotes, addNote, listNotes, addNote,
listArtifacts, createArtifact, updateArtifact, deleteArtifact, listArtifacts, createArtifact, updateArtifact, deleteArtifact,
getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverage, getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverageForRepo,
listGiteaBranches, createGiteaBranch, listGiteaBranches, createGiteaBranch,
aiAssist, aiAssist,
type LinkedCommit, type PinnedCommit, type BranchCoverage, type GiteaBranch, type LinkedCommit, type PinnedCommit, type BranchCoverage, type GiteaBranch,
@@ -70,6 +70,49 @@ const ARTIFACT_TABS: { type: ArtifactType; label: string; placeholder: string }[
{ type: 'QaTestPath', label: 'QA Test Paths', placeholder: 'Step-by-step QA test scenarios, edge cases, regression checks...' }, { type: 'QaTestPath', label: 'QA Test Paths', placeholder: 'Step-by-step QA test scenarios, edge cases, regression checks...' },
]; ];
// -- SDLC delivery chain -------------------------------------------------------
const SDLC_STAGES: { branch: string; label: string; intent: Intent }[] = [
{ branch: 'develop', label: 'Dev', intent: Intent.PRIMARY },
{ branch: 'staging', label: 'Staging', intent: Intent.WARNING },
{ branch: 'uat', label: 'UAT', intent: Intent.DANGER },
{ branch: 'master', label: 'Production', intent: Intent.SUCCESS },
];
function deriveSdlcSummary(coverage: BranchCoverage[]): { label: string; intent: Intent } | null {
for (let i = SDLC_STAGES.length - 1; i >= 0; i--) {
const stage = SDLC_STAGES[i];
const hit = coverage.find(c => c.branch === stage.branch);
if (hit?.contains) return { label: stage.label, intent: stage.intent };
}
return null;
}
// Aggregate per-repo branch coverage into a single view.
// A stage is "reached" only when every repo that recognised at least one hash
// reports contains=true for that branch. Repos that recognised no hashes are
// excluded from the constraint (they have no code linked to this OPC).
const KNOWN_REPOS = ['Clarity', 'OPC', 'Gateway'] as const;
type RepoCoverage = { repoKey: string; coverage: BranchCoverage[] };
function aggregateCoverage(perRepo: RepoCoverage[]): BranchCoverage[] {
const active = perRepo.filter(r => r.coverage.length > 0);
if (active.length === 0) return [];
const branches = [...new Set(active.flatMap(r => r.coverage.map(c => c.branch)))];
return branches.map(branch => {
const entries = active
.map(r => r.coverage.find(c => c.branch === branch))
.filter((c): c is BranchCoverage => c !== undefined);
return {
branch,
contains: entries.length > 0 && entries.every(c => c.contains),
tipHash: entries[0]?.tipHash ?? '',
isHead: entries.some(c => c.isHead),
};
});
}
// -- Helpers ------------------------------------------------------------------- // -- Helpers -------------------------------------------------------------------
function fmtDate(iso: string): string { function fmtDate(iso: string): string {
@@ -232,6 +275,14 @@ function ArtifactPanel({ opcId, opcNumber, artifactType, placeholder }: {
); );
} }
// -- Repo badge ---------------------------------------------------------------
const REPO_INTENT: Record<string, Intent> = {
Clarity: Intent.PRIMARY,
OPC: Intent.WARNING,
Gateway: Intent.SUCCESS,
};
// -- Commit row (shared) ------------------------------------------------------- // -- Commit row (shared) -------------------------------------------------------
function CommitRow({ commit, onPin, isPinned, onViewDiff }: { commit: LinkedCommit; onPin?: () => void; isPinned?: boolean; onViewDiff?: (hash: string) => void }) { function CommitRow({ commit, onPin, isPinned, onViewDiff }: { commit: LinkedCommit; onPin?: () => void; isPinned?: boolean; onViewDiff?: (hash: string) => void }) {
@@ -243,6 +294,13 @@ function CommitRow({ commit, onPin, isPinned, onViewDiff }: { commit: LinkedComm
{commit.shortHash} {commit.shortHash}
</code> </code>
</Tooltip> </Tooltip>
{commit.repoKey && commit.repoKey !== 'unknown' && (
<Tag minimal round small
intent={REPO_INTENT[commit.repoKey] ?? Intent.NONE}
style={{ fontSize: '0.68rem', flexShrink: 0 }}>
{commit.repoKey}
</Tag>
)}
<div className="opc-commit-info"> <div className="opc-commit-info">
<div className="opc-commit-msg">{commit.subject}</div> <div className="opc-commit-msg">{commit.subject}</div>
<div className="opc-commit-meta">{commit.author} · {commit.date}</div> <div className="opc-commit-meta">{commit.author} · {commit.date}</div>
@@ -297,7 +355,15 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
setAutoCommits(auto); setAutoCommits(auto);
setPinned(pins); setPinned(pins);
const allHashes = [...new Set([...auto.map(c => c.hash), ...pins.map(c => c.hash)])]; const allHashes = [...new Set([...auto.map(c => c.hash), ...pins.map(c => c.hash)])];
if (allHashes.length > 0) setCoverage(await getBranchCoverage(allHashes)); if (allHashes.length > 0) {
const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
}
} catch { /* non-critical — API may not be up */ } } catch { /* non-critical — API may not be up */ }
finally { setLoaded(true); } finally { setLoaded(true); }
})(); })();
@@ -329,7 +395,13 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
setPinned(prev => [...prev, c]); setPinned(prev => [...prev, c]);
setPinInput(''); setPinInput('');
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])]; const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])];
setCoverage(await getBranchCoverage(allHashes)); const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
} catch (e) { setPinError(String(e)); } } catch (e) { setPinError(String(e)); }
finally { setPinning(false); } finally { setPinning(false); }
}; };
@@ -339,14 +411,33 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
const c = await pinCommit(opc.id, commit.hash, 'amadzarak'); const c = await pinCommit(opc.id, commit.hash, 'amadzarak');
setPinned(prev => [...prev, c]); setPinned(prev => [...prev, c]);
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])]; const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])];
setCoverage(await getBranchCoverage(allHashes)); const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
} catch { /* no-op */ } } catch { /* no-op */ }
}; };
const handleUnpin = async (hash: string) => { const handleUnpin = async (hash: string) => {
try { try {
await unpinCommit(opc.id, hash); await unpinCommit(opc.id, hash);
setPinned(prev => prev.filter(c => c.hash !== hash)); const remaining = pinned.filter(c => c.hash !== hash);
setPinned(remaining);
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...remaining.map(x => x.hash)])];
if (allHashes.length > 0) {
const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
} else {
setCoverage([]);
}
} catch { /* no-op */ } } catch { /* no-op */ }
}; };
@@ -367,7 +458,7 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
{linkedBranch.name} {linkedBranch.name}
</Tag> </Tag>
<a <a
href={`https://opc.clarity.test/Clarity/Clarity/src/branch/${encodeURIComponent(linkedBranch.name)}`} href={`https://opc.clarity.test/ClarityStack/Clarity/src/branch/${encodeURIComponent(linkedBranch.name)}`}
target="_blank" rel="noreferrer" target="_blank" rel="noreferrer"
style={{ fontSize: '0.8rem', color: 'var(--bp4-intent-primary)' }}> style={{ fontSize: '0.8rem', color: 'var(--bp4-intent-primary)' }}>
Open in Gitea Open in Gitea
@@ -394,26 +485,47 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
</div> </div>
<Divider style={{ margin: '0.5rem 0 0.75rem' }} /> <Divider style={{ margin: '0.5rem 0 0.75rem' }} />
{/* Branch coverage */} {/* SDLC Delivery Chain */}
{coverage.length > 0 && ( {coverage.length > 0 && (() => {
<div className="opc-branch-coverage"> const summary = deriveSdlcSummary(coverage);
<div className="opc-field-label" style={{ marginBottom: '0.5rem' }}>Branch Coverage</div> return (
<div className="opc-branch-chips"> <div className="opc-delivery-chain">
{coverage.map(b => ( <div className="opc-field-label" style={{ marginBottom: '0.6rem' }}>Delivery Chain</div>
<Tooltip key={b.branch} <div className="opc-sdlc-pipeline">
content={b.contains {SDLC_STAGES.map((stage, i) => {
? `All linked commits reachable from ${b.branch}` const hit = coverage.find(c => c.branch === stage.branch);
: `Not all linked commits have reached ${b.branch} yet`}> const reached = hit?.contains ?? false;
<Tag intent={b.contains ? Intent.SUCCESS : Intent.NONE} return (
icon={b.contains ? 'tick-circle' : 'minus'} <div key={stage.branch} className="opc-sdlc-stage-item">
minimal={!b.contains} round> {i > 0 && <span className="opc-sdlc-arrow"></span>}
{b.branch}{b.isHead ? ' ★' : ''} <Tooltip content={
</Tag> reached
</Tooltip> ? `All linked commits have reached ${stage.label}`
))} : hit
? `Not all linked commits have reached ${stage.label} yet`
: `${stage.label} branch not found locally`
}>
<Tag
intent={reached ? stage.intent : Intent.NONE}
icon={reached ? 'tick-circle' : 'circle'}
minimal={!reached}
round
>
{stage.label}
</Tag>
</Tooltip>
</div>
);
})}
</div>
{summary && (
<div className="opc-sdlc-furthest">
Furthest: <strong>{summary.label}</strong>
</div>
)}
</div> </div>
</div> );
)} })()}
{/* Auto-detected */} {/* Auto-detected */}
<div className="opc-commits-section-label"> <div className="opc-commits-section-label">
@@ -615,8 +727,8 @@ function OpcDetailDrawer({ opc, onClose, onUpdate }: {
</div> </div>
} /> } />
{/* Commits */} {/* Code & SDLC */}
<Tab id="commits" title="Commits" panel={ <Tab id="commits" title="Code & SDLC" panel={
<CommitsTab opc={opc} isActive={activeTab === 'commits'} /> <CommitsTab opc={opc} isActive={activeTab === 'commits'} />
} /> } />
</Tabs> </Tabs>
@@ -666,7 +778,7 @@ function OpcCreateDrawer({ onClose, onCreate }: { onClose: () => void; onCreate:
<Callout intent={Intent.PRIMARY} icon="info-sign" style={{ marginBottom: '1.25rem' }}> <Callout intent={Intent.PRIMARY} icon="info-sign" style={{ marginBottom: '1.25rem' }}>
An OPC tracks a change, task, or business requirement through its full lifecycle. An OPC tracks a change, task, or business requirement through its full lifecycle.
{nextNumber && <> This will be saved as <strong>{nextNumber}</strong>.</>}{' '} {nextNumber && <> This will be saved as <strong>{nextNumber}</strong>.</>}{' '}
Include the OPC number in commit messages on <code>develop</code> to link check-ins automatically. Include the OPC number in commit messages to link check-ins automatically across any repo.
</Callout> </Callout>
{error && <Callout intent={Intent.DANGER} style={{ marginBottom: '1rem' }}>{error}</Callout>} {error && <Callout intent={Intent.DANGER} style={{ marginBottom: '1rem' }}>{error}</Callout>}
<FormGroup label="Title" labelInfo="(required)"> <FormGroup label="Title" labelInfo="(required)">
@@ -746,7 +858,7 @@ export default function OpcPage() {
<div className="page-header"> <div className="page-header">
<div> <div>
<h1>OPC</h1> <h1>OPC</h1>
<p>Online Project Communication change orders, tasks, and business requirements.</p> <p>Online Project Communication track changes, requirements, and SDLC delivery chain.</p>
</div> </div>
<Button intent={Intent.PRIMARY} icon="plus" text="New OPC" <Button intent={Intent.PRIMARY} icon="plus" text="New OPC"
onClick={() => { setSelected(null); setCreating(true); }} /> onClick={() => { setSelected(null); setCreating(true); }} />