OPC # 0001: Extract OPC into standalone repo
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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`
|
||||||
@@ -14,19 +14,33 @@ 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)>();
|
||||||
|
|
||||||
|
foreach (var (repoKey, repoPath) in repos)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(repoPath)) continue;
|
||||||
|
|
||||||
|
using var r = new Repository(repoPath);
|
||||||
|
|
||||||
|
var tips = r.Branches
|
||||||
.Where(b => b.Tip != null)
|
.Where(b => b.Tip != null)
|
||||||
.Select(b => (GitObject)b.Tip)
|
.Select(b => (GitObject)b.Tip)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -34,17 +48,31 @@ public static class GitEndpoints
|
|||||||
var filter = new CommitFilter
|
var filter = new CommitFilter
|
||||||
{
|
{
|
||||||
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
|
||||||
IncludeReachableFrom = tips.Count > 0 ? tips : (object)repo.Head,
|
IncludeReachableFrom = tips.Count > 0 ? tips : (object)r.Head,
|
||||||
};
|
};
|
||||||
|
|
||||||
IEnumerable<Commit> query = repo.Commits.QueryBy(filter);
|
IEnumerable<Commit> query = r.Commits.QueryBy(filter);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(grep))
|
if (!string.IsNullOrWhiteSpace(grep))
|
||||||
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
|
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var commits = query
|
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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={
|
||||||
|
reached
|
||||||
|
? `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>
|
</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<div className="opc-sdlc-furthest">
|
||||||
|
Furthest: <strong>{summary.label}</strong>
|
||||||
</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); }} />
|
||||||
|
|||||||
Reference in New Issue
Block a user