From 76962a6af4b87bd0ede5bd2b5258b288ad20a80c Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sat, 25 Apr 2026 19:17:48 -0400 Subject: [PATCH] OPC # 0001: Extract OPC into standalone repo Co-authored-by: Copilot --- .github/copilot-instructions.md | 47 +++++- ControlPlane.Api/Endpoints/GitEndpoints.cs | 108 +++++++++---- ControlPlane.Api/appsettings.json | 13 +- clarity.controlplane/src/api/opcApi.ts | 16 +- clarity.controlplane/src/index.css | 37 +++++ clarity.controlplane/src/opc/OpcPage.tsx | 170 +++++++++++++++++---- 6 files changed, 326 insertions(+), 65 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b5c16fb..93207b2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,45 @@ -# Copilot Instructions +# OPC (ControlPlane) — Copilot Instructions -## Project Guidelines -- 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. \ No newline at end of file +## What is OPC? +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` \ No newline at end of file diff --git a/ControlPlane.Api/Endpoints/GitEndpoints.cs b/ControlPlane.Api/Endpoints/GitEndpoints.cs index b526964..e6a3495 100644 --- a/ControlPlane.Api/Endpoints/GitEndpoints.cs +++ b/ControlPlane.Api/Endpoints/GitEndpoints.cs @@ -14,37 +14,65 @@ public static class GitEndpoints 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( IConfiguration config, string? grep = null, - int limit = 50) + int limit = 50, + string repo = "all") { - var repoPath = ResolveRepo(config); - if (repoPath is null) - return Results.Problem("Could not locate a git repository. Set Git:RepoRoot in appsettings."); + var repos = repo == "all" + ? ResolveAllRepos(config) + : ResolveNamedRepo(config, repo) is { } p + ? new Dictionary { [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 - .Where(b => b.Tip != null) - .Select(b => (GitObject)b.Tip) - .ToList(); + var bucket = new List<(DateTimeOffset When, string RepoKey, GitCommit Commit)>(); - var filter = new CommitFilter + foreach (var (repoKey, repoPath) in repos) { - SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, - IncludeReachableFrom = tips.Count > 0 ? tips : (object)repo.Head, - }; + if (!Directory.Exists(repoPath)) continue; - IEnumerable query = repo.Commits.QueryBy(filter); + using var r = new Repository(repoPath); - if (!string.IsNullOrWhiteSpace(grep)) - query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); + var tips = r.Branches + .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 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) - .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(); return Results.Ok(commits); @@ -114,36 +142,39 @@ public static class GitEndpoints return Results.Ok(branches); } - // GET /api/git/branch-coverage?commits=hash1,hash2,hash3 - // Returns each local branch and whether it contains ALL of the given commits. - private static IResult GetBranchCoverage(IConfiguration config, string? commits = null) + // GET /api/git/branch-coverage?commits=hash1,hash2,hash3&repo=OPC + // repo defaults to the single configured repo. Pass a named key from Git:Repos to + // 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()); var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (hashes.Length == 0) return Results.Ok(Array.Empty()); - var repoPath = ResolveRepo(config); + var repoPath = repo is not null + ? ResolveNamedRepo(config, repo) ?? ResolveRepo(config) + : ResolveRepo(config); if (repoPath is null) return Results.Problem("Could not locate a git repository."); - using var repo = new Repository(repoPath); + using var r = new Repository(repoPath); var targetCommits = hashes - .Select(h => repo.Lookup(h)) + .Select(h => r.Lookup(h)) .Where(c => c is not null) .ToList(); if (targetCommits.Count == 0) return Results.Ok(Array.Empty()); - var result = repo.Branches + var result = r.Branches .Where(b => !b.IsRemote && b.Tip != null) .Select(b => { var contains = targetCommits.All(tc => { // 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 new @@ -162,6 +193,29 @@ public static class GitEndpoints // ── 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 ResolveAllRepos(IConfiguration config) + { + var result = new Dictionary(); + 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 /// from the running assembly directory upward via LibGit2Sharp. private static string? ResolveRepo(IConfiguration config) diff --git a/ControlPlane.Api/appsettings.json b/ControlPlane.Api/appsettings.json index 588532d..a043c74 100644 --- a/ControlPlane.Api/appsettings.json +++ b/ControlPlane.Api/appsettings.json @@ -10,12 +10,17 @@ "ApiKey": "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" }, "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": { "BaseUrl": "https://opc.clarity.test", - "Owner": "Clarity", - "Repo": "Clarity", - "Token": "2ef325f682915c5959bf6a0dc73cec7034fcd2a2" + "Owner": "ClarityStack", + "Repo": "OPC", + "Token": "fcf9f66415754fb639a8343e3904e06b1d78c646" } } \ No newline at end of file diff --git a/clarity.controlplane/src/api/opcApi.ts b/clarity.controlplane/src/api/opcApi.ts index 6415e95..c4197ab 100644 --- a/clarity.controlplane/src/api/opcApi.ts +++ b/clarity.controlplane/src/api/opcApi.ts @@ -114,6 +114,7 @@ export async function deleteArtifact(artifactId: string): Promise { // The git log endpoint supports ?grep=OPC+%230001 to filter. export interface LinkedCommit { + repoKey: string; hash: string; shortHash: string; author: string; @@ -123,9 +124,9 @@ export interface LinkedCommit { } export async function getLinkedCommits(opcNumber: string): Promise { - 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}`); - return res.json(); + return res.json(); // backend now returns { repoKey, hash, shortHash, author, date, subject, files } } // ── Pinned commits ──────────────────────────────────────────────────────────── @@ -192,6 +193,17 @@ export async function getBranchCoverage(hashes: string[]): Promise { + 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) ───────────────────────────────────────────────── export interface CommitFile { diff --git a/clarity.controlplane/src/index.css b/clarity.controlplane/src/index.css index 4143b89..60f35b3 100644 --- a/clarity.controlplane/src/index.css +++ b/clarity.controlplane/src/index.css @@ -758,6 +758,43 @@ body { 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 */ .opc-commits-section-label { display: flex; diff --git a/clarity.controlplane/src/opc/OpcPage.tsx b/clarity.controlplane/src/opc/OpcPage.tsx index a14b30e..f943582 100644 --- a/clarity.controlplane/src/opc/OpcPage.tsx +++ b/clarity.controlplane/src/opc/OpcPage.tsx @@ -11,7 +11,7 @@ import { listOpcs, createOpc, updateOpc, getNextNumber, listNotes, addNote, listArtifacts, createArtifact, updateArtifact, deleteArtifact, - getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverage, + getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverageForRepo, listGiteaBranches, createGiteaBranch, aiAssist, 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...' }, ]; +// -- 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 ------------------------------------------------------------------- function fmtDate(iso: string): string { @@ -232,6 +275,14 @@ function ArtifactPanel({ opcId, opcNumber, artifactType, placeholder }: { ); } +// -- Repo badge --------------------------------------------------------------- + +const REPO_INTENT: Record = { + Clarity: Intent.PRIMARY, + OPC: Intent.WARNING, + Gateway: Intent.SUCCESS, +}; + // -- Commit row (shared) ------------------------------------------------------- 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.repoKey && commit.repoKey !== 'unknown' && ( + + {commit.repoKey} + + )}
{commit.subject}
{commit.author} · {commit.date}
@@ -297,7 +355,15 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) { setAutoCommits(auto); setPinned(pins); 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 */ } finally { setLoaded(true); } })(); @@ -329,7 +395,13 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) { setPinned(prev => [...prev, c]); setPinInput(''); 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)); } finally { setPinning(false); } }; @@ -339,14 +411,33 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) { const c = await pinCommit(opc.id, commit.hash, 'amadzarak'); setPinned(prev => [...prev, c]); 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 */ } }; const handleUnpin = async (hash: string) => { try { 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 */ } }; @@ -367,7 +458,7 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) { {linkedBranch.name} Open in Gitea ↗ @@ -394,26 +485,47 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
- {/* Branch coverage */} - {coverage.length > 0 && ( -
-
Branch Coverage
-
- {coverage.map(b => ( - - - {b.branch}{b.isHead ? ' ★' : ''} - - - ))} + {/* SDLC Delivery Chain */} + {coverage.length > 0 && (() => { + const summary = deriveSdlcSummary(coverage); + return ( +
+
Delivery Chain
+
+ {SDLC_STAGES.map((stage, i) => { + const hit = coverage.find(c => c.branch === stage.branch); + const reached = hit?.contains ?? false; + return ( +
+ {i > 0 && } + + + {stage.label} + + +
+ ); + })} +
+ {summary && ( +
+ Furthest: {summary.label} +
+ )}
-
- )} + ); + })()} {/* Auto-detected */}
@@ -615,8 +727,8 @@ function OpcDetailDrawer({ opc, onClose, onUpdate }: {
} /> - {/* Commits */} - } /> @@ -666,7 +778,7 @@ function OpcCreateDrawer({ onClose, onCreate }: { onClose: () => void; onCreate: An OPC tracks a change, task, or business requirement through its full lifecycle. {nextNumber && <> This will be saved as {nextNumber}.}{' '} - Include the OPC number in commit messages on develop to link check-ins automatically. + Include the OPC number in commit messages to link check-ins automatically across any repo. {error && {error}} @@ -746,7 +858,7 @@ export default function OpcPage() {

OPC

-

Online Project Communication — change orders, tasks, and business requirements.

+

Online Project Communication — track changes, requirements, and SDLC delivery chain.