From c7da1eb017ffe72bbc1bfc5962180d94f17aa61b Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sun, 26 Apr 2026 16:31:57 -0400 Subject: [PATCH] OPC # 0006: OPC Git Trunk-Based management --- ControlPlane.Api/Endpoints/GitEndpoints.cs | 136 +++++-- clarity.controlplane/src/api/opcApi.ts | 26 +- clarity.controlplane/src/index.css | 47 +++ .../src/pages/ChangesetsPage.tsx | 354 ++++++++++++------ 4 files changed, 414 insertions(+), 149 deletions(-) diff --git a/ControlPlane.Api/Endpoints/GitEndpoints.cs b/ControlPlane.Api/Endpoints/GitEndpoints.cs index 3e7dca4..78a0c4b 100644 --- a/ControlPlane.Api/Endpoints/GitEndpoints.cs +++ b/ControlPlane.Api/Endpoints/GitEndpoints.cs @@ -19,13 +19,19 @@ public static class GitEndpoints // All responses include a repoKey field so the caller knows which repo each commit came from. // GET /api/git/log?grep=...&limit=25&page=1&repo=all // page is 1-based. Each repo contributes up to limit*page commits before merge-sort+skip/take. + // branch filters to commits reachable from the named local branch; requires a specific repo (not "all"). + // Each result includes a branches[] array listing local branches that contain that commit. private static IResult GetLog( IConfiguration config, - string? grep = null, - int limit = 25, - int page = 1, - string repo = "all") + string? grep = null, + int limit = 25, + int page = 1, + string repo = "all", + string? branch = null) { + if (!string.IsNullOrWhiteSpace(branch) && repo == "all") + return Results.BadRequest("'branch' filter requires a specific 'repo' parameter."); + var repos = repo == "all" ? ResolveAllRepos(config) : ResolveNamedRepo(config, repo) is { } p @@ -43,16 +49,30 @@ public static class GitEndpoints using var r = new Repository(repoPath); - var tips = r.Branches - .Where(b => b.Tip != null) - .Select(b => (GitObject)b.Tip) - .ToList(); - - var filter = new CommitFilter + CommitFilter filter; + if (!string.IsNullOrWhiteSpace(branch)) { - SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, - IncludeReachableFrom = tips.Count > 0 ? tips : (object)r.Head, - }; + var branchRef = r.Branches[branch]; + if (branchRef?.Tip is null) + return Results.BadRequest($"Branch '{branch}' not found in repo '{repoKey}'."); + filter = new CommitFilter + { + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, + IncludeReachableFrom = branchRef.Tip, + }; + } + else + { + var tips = r.Branches + .Where(b => b.Tip != null) + .Select(b => (GitObject)b.Tip) + .ToList(); + filter = new CommitFilter + { + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, + IncludeReachableFrom = tips.Count > 0 ? tips : (object)r.Head, + }; + } IEnumerable query = r.Commits.QueryBy(filter); @@ -63,22 +83,42 @@ public static class GitEndpoints bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c))); } - var commits = bucket + var pageEntries = bucket .OrderByDescending(x => x.When) .Skip((page - 1) * limit) .Take(limit) - .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(); + // Annotate each commit with the local branches whose tip is a descendant of that commit. + var branchMap = new Dictionary>(StringComparer.Ordinal); + foreach (var grp in pageEntries.GroupBy(x => x.RepoKey)) + { + if (!repos.TryGetValue(grp.Key, out var rPath) || !Directory.Exists(rPath)) continue; + using var annotRepo = new Repository(rPath); + var localBranches = annotRepo.Branches.Where(b => !b.IsRemote && b.Tip != null).ToList(); + foreach (var (_, _, c) in grp) + { + var target = annotRepo.Lookup(c.Hash); + if (target is null) { branchMap[c.Hash] = []; continue; } + branchMap[c.Hash] = localBranches + .Where(b => annotRepo.ObjectDatabase.FindMergeBase(b.Tip, target)?.Sha == target.Sha) + .Select(b => b.FriendlyName) + .ToList(); + } + } + + var commits = pageEntries.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, + branches = branchMap.TryGetValue(x.Commit.Hash, out var br) ? (IReadOnlyList)br : Array.Empty(), + }).ToList(); + return Results.Ok(commits); } @@ -127,18 +167,55 @@ public static class GitEndpoints return Results.NotFound(); } - // GET /api/git/branches - private static IResult GetBranches(IConfiguration config) + // GET /api/git/branches?repo=all + // repo defaults to "default" (single configured repo). Pass "all" to get branches from all + // registered repos. Each result includes repoKey so the caller can group branches by repo. + private static IResult GetBranches(IConfiguration config, string repo = "default") { - var repoPath = ResolveRepo(config); + if (repo == "all") + { + var allRepos = ResolveAllRepos(config); + if (allRepos.Count == 0) + return Results.Problem("Could not locate any git repositories."); + + var allBranches = new List(); + foreach (var (repoKey, rPath) in allRepos) + { + if (!Directory.Exists(rPath)) continue; + using var gitRepo = new Repository(rPath); + allBranches.AddRange(gitRepo.Branches + .Where(b => !b.IsRemote && b.Tip != null) + .OrderBy(b => b.FriendlyName) + .Select(b => (object)new + { + repoKey, + name = b.FriendlyName, + hash = b.Tip.Sha, + shortHash = b.Tip.Sha[..7], + subject = b.Tip.MessageShort, + author = b.Tip.Author.Name, + date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), + isHead = b.IsCurrentRepositoryHead, + })); + } + return Results.Ok(allBranches); + } + + var repoPath = repo == "default" + ? ResolveRepo(config) + : ResolveNamedRepo(config, repo) ?? ResolveRepo(config); + if (repoPath is null) return Results.Problem("Could not locate a git repository."); - using var repo = new Repository(repoPath); - var branches = repo.Branches + var repoLabel = repo == "default" ? "default" : repo; + using var singleRepo = new Repository(repoPath); + var branches = singleRepo.Branches .Where(b => !b.IsRemote && b.Tip != null) + .OrderBy(b => b.FriendlyName) .Select(b => new { + repoKey = repoLabel, name = b.FriendlyName, hash = b.Tip.Sha, shortHash = b.Tip.Sha[..7], @@ -147,7 +224,6 @@ public static class GitEndpoints date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), isHead = b.IsCurrentRepositoryHead, }) - .OrderBy(b => b.name) .ToList(); return Results.Ok(branches); diff --git a/clarity.controlplane/src/api/opcApi.ts b/clarity.controlplane/src/api/opcApi.ts index edad01f..b6e3b7a 100644 --- a/clarity.controlplane/src/api/opcApi.ts +++ b/clarity.controlplane/src/api/opcApi.ts @@ -121,6 +121,7 @@ export interface LinkedCommit { date: string; subject: string; files: string[]; + branches: string[]; } export async function getLinkedCommits(opcNumber: string): Promise { @@ -208,18 +209,35 @@ export async function getBranchCoverageForRepo(repoKey: string, hashes: string[] // Reuses LinkedCommit shape — repoKey identifies which repo each commit came from. export async function getChangesets( - page = 1, - limit = 25, - repo = 'all', + page = 1, + limit = 25, + repo = 'all', grep?: string, + branch?: string, ): Promise { const params = new URLSearchParams({ page: String(page), limit: String(limit), repo }); - if (grep) params.set('grep', grep); + if (grep) params.set('grep', grep); + if (branch) params.set('branch', branch); const res = await fetch(`${BASE_URL}/api/git/log?${params}`); if (!res.ok) throw new Error(`Failed to load changesets: ${res.statusText}`); return res.json(); } +// ── Local git branches (from LibGit2 repos) ─────────────────────────────────── + +export interface RepoBranchInfo { + repoKey: string; + name: string; + hash: string; + isHead: boolean; +} + +export async function getBranches(repo = 'all'): Promise { + const res = await fetch(`${BASE_URL}/api/git/branches?repo=${encodeURIComponent(repo)}`); + if (!res.ok) throw new Error(`Failed to load branches: ${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 e1feaa3..b4d3e7f 100644 --- a/clarity.controlplane/src/index.css +++ b/clarity.controlplane/src/index.css @@ -1292,3 +1292,50 @@ body { min-width: 60px; text-align: center; } + +.cs-page-with-tree { + display: flex; + gap: 1.5rem; + align-items: flex-start; +} + +.cs-branch-tree { + width: 200px; + flex-shrink: 0; + position: sticky; + top: 1rem; + border: 1px solid #dce0e6; + border-radius: 8px; + overflow: hidden; + padding: 0.25rem 0; + max-height: calc(100vh - 8rem); + overflow-y: auto; +} + +.cs-branch-tree .bp6-tree-node-content { + font-size: 0.82rem; + height: 28px; +} + +.cs-branch-tree .bp6-tree-node-content:hover { + background: #f6f7f9; +} + +.cs-branch-tree .bp6-tree-node.bp6-tree-node-selected > .bp6-tree-node-content { + background: #e8f0fb; + color: #215db0; +} + +.cs-content-area { + flex: 1; + min-width: 0; +} + +.cs-branch-badge { + font-size: 0.67rem; + letter-spacing: 0.01em; + white-space: nowrap; + margin-left: 0.5rem; + opacity: 0.85; + vertical-align: middle; +} diff --git a/clarity.controlplane/src/pages/ChangesetsPage.tsx b/clarity.controlplane/src/pages/ChangesetsPage.tsx index ff854fb..5ee7c1a 100644 --- a/clarity.controlplane/src/pages/ChangesetsPage.tsx +++ b/clarity.controlplane/src/pages/ChangesetsPage.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Button, HTMLSelect, InputGroup, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Button, HTMLSelect, InputGroup, Intent, NonIdealState, Spinner, Tag, Tooltip, Tree } from '@blueprintjs/core'; +import type { TreeNodeInfo } from '@blueprintjs/core'; import { GitCommitDrawer } from '../components/GitCommitDrawer'; -import { getChangesets } from '../api/opcApi'; +import { getChangesets, getBranches } from '../api/opcApi'; import type { LinkedCommit } from '../api/opcApi'; const PAGE_SIZE = 25; @@ -19,23 +20,62 @@ const REPO_INTENT: Record = { Gateway: Intent.SUCCESS, }; -export default function ChangesetsPage() { - const [commits, setCommits] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(false); - const [repoFilter, setRepoFilter] = useState('all'); - const [grepFilter, setGrepFilter] = useState(''); - const [grepInput, setGrepInput] = useState(''); - const [viewingHash, setViewingHash] = useState(null); +const REPO_COLOR: Record = { + Clarity: '#215db0', + OPC: '#935610', + Gateway: '#1c6e42', +}; - const load = useCallback(async (p: number, repo: string, grep: string) => { +const SDLC_ORDER = ['develop', 'staging', 'uat', 'main'] as const; + +function getBranchLabel(branches: string[]): string | null { + if (branches.length === 0) return null; + const sdlc = branches.filter(b => (SDLC_ORDER as readonly string[]).includes(b)); + if (sdlc.length === 0) return branches[0]; + const highest = sdlc.reduce((a, b) => + SDLC_ORDER.indexOf(b as typeof SDLC_ORDER[number]) > SDLC_ORDER.indexOf(a as typeof SDLC_ORDER[number]) ? b : a, + ); + const idx = SDLC_ORDER.indexOf(highest as typeof SDLC_ORDER[number]); + if (idx > 0) { + const prev = SDLC_ORDER[idx - 1]; + if (branches.includes(prev)) return `${prev} → ${highest}`; + } + return highest; +} + +export default function ChangesetsPage() { + const [commits, setCommits] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [repoFilter, setRepoFilter] = useState('all'); + const [grepFilter, setGrepFilter] = useState(''); + const [grepInput, setGrepInput] = useState(''); + const [viewingHash, setViewingHash] = useState(null); + const [branchMap, setBranchMap] = useState>({}); + const [selectedBranch, setSelectedBranch] = useState<{ repoKey: string; branch: string } | null>(null); + const [expandedRepos, setExpandedRepos] = useState>(new Set()); + + // Load branches for the tree once on mount + useEffect(() => { + getBranches('all') + .then(data => { + const map: Record = {}; + for (const b of data) { + if (!map[b.repoKey]) map[b.repoKey] = []; + map[b.repoKey].push(b.name); + } + setBranchMap(map); + }) + .catch(() => { /* branch tree is non-critical */ }); + }, []); + + const load = useCallback(async (p: number, repo: string, grep: string, branch?: string) => { setLoading(true); setError(null); try { - // Fetch one extra to detect whether there's a next page - const rows = await getChangesets(p, PAGE_SIZE + 1, repo, grep || undefined); + const rows = await getChangesets(p, PAGE_SIZE + 1, repo, grep || undefined, branch); setHasMore(rows.length > PAGE_SIZE); setCommits(rows.slice(0, PAGE_SIZE)); } catch (e) { @@ -47,25 +87,77 @@ export default function ChangesetsPage() { }, []); useEffect(() => { - load(page, repoFilter, grepFilter); - }, [load, page, repoFilter, grepFilter]); + const effectiveRepo = selectedBranch ? selectedBranch.repoKey : repoFilter; + const effectiveBranch = selectedBranch?.branch; + load(page, effectiveRepo, grepFilter, effectiveBranch); + }, [load, page, repoFilter, grepFilter, selectedBranch]); - const applySearch = () => { - setPage(1); - setGrepFilter(grepInput); - }; - - const clearSearch = () => { - setGrepInput(''); - setGrepFilter(''); - setPage(1); - }; + const applySearch = () => { setPage(1); setGrepFilter(grepInput); }; + const clearSearch = () => { setGrepInput(''); setGrepFilter(''); setPage(1); }; const handleRepoChange = (repo: string) => { setPage(1); + setSelectedBranch(null); setRepoFilter(repo); }; + const treeNodes = useMemo((): TreeNodeInfo[] => [ + { + id: 'all', + label: 'All Branches', + icon: 'git-branch', + isSelected: selectedBranch === null, + }, + ...Object.keys(branchMap).map(repoKey => ({ + id: `repo|${repoKey}`, + label: {repoKey}, + icon: (expandedRepos.has(repoKey) ? 'folder-open' : 'folder-close') as TreeNodeInfo['icon'], + isExpanded: expandedRepos.has(repoKey), + isSelected: false, + childNodes: branchMap[repoKey].map(name => ({ + id: `branch|${repoKey}|${name}`, + label: name, + icon: 'git-commit' as TreeNodeInfo['icon'], + isSelected: selectedBranch?.repoKey === repoKey && selectedBranch?.branch === name, + })), + })), + ], [branchMap, expandedRepos, selectedBranch]); + + const handleNodeClick = (node: TreeNodeInfo) => { + const id = String(node.id); + if (id === 'all') { + setPage(1); + setSelectedBranch(null); + } else if (id.startsWith('branch|')) { + const [, repoKey, ...rest] = id.split('|'); + setPage(1); + setSelectedBranch({ repoKey, branch: rest.join('|') }); + } else if (id.startsWith('repo|')) { + const repoKey = id.slice(5); + setExpandedRepos(s => { + const next = new Set(s); + next.has(repoKey) ? next.delete(repoKey) : next.add(repoKey); + return next; + }); + } + }; + + const handleNodeExpand = (node: TreeNodeInfo) => { + const id = String(node.id); + if (id.startsWith('repo|')) setExpandedRepos(s => new Set([...s, id.slice(5)])); + }; + + const handleNodeCollapse = (node: TreeNodeInfo) => { + const id = String(node.id); + if (id.startsWith('repo|')) setExpandedRepos(s => { const n = new Set(s); n.delete(id.slice(5)); return n; }); + }; + + const doRefresh = () => { + const effectiveRepo = selectedBranch ? selectedBranch.repoKey : repoFilter; + const effectiveBranch = selectedBranch?.branch; + load(page, effectiveRepo, grepFilter, effectiveBranch); + }; + return (
@@ -73,100 +165,132 @@ export default function ChangesetsPage() {

Changesets

Chronological commit timeline across all three repos.

-
- {/* Filter bar */} -
- handleRepoChange(e.target.value)} - options={REPO_OPTIONS} - /> - setGrepInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') applySearch(); }} - style={{ width: 320 }} - rightElement={ -
+
+ {/* Branch tree */} +
+ +
- {/* Content */} - {loading ? ( - } title="Loading changesets…" /> - ) : error ? ( - - ) : commits.length === 0 ? ( - - ) : ( -
- {commits.map(c => ( -
setViewingHash(c.hash)} - > -
- - {c.repoKey || '?'} - -
+ {/* Main content */} +
+ {/* Filter bar */} +
+ handleRepoChange(e.target.value)} + options={REPO_OPTIONS} + disabled={!!selectedBranch} + /> + setGrepInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') applySearch(); }} + style={{ width: 320 }} + rightElement={ +
-
-
- - {c.shortHash} - - {c.subject} -
-
- {c.author} · {c.date} - {c.files.length > 0 && ( - · {c.files.length} file{c.files.length !== 1 ? 's' : ''} - )} -
-
+ {/* Content */} + {loading ? ( + } title="Loading changesets…" /> + ) : error ? ( + + ) : commits.length === 0 ? ( + + ) : ( +
+ {commits.map(c => { + const branchLabel = getBranchLabel(c.branches ?? []); + return ( +
setViewingHash(c.hash)} + > +
+ + {c.repoKey || '?'} + +
+ +
+
+ + {c.shortHash} + + {c.subject} +
+
+ {c.author} · {c.date} + {c.files.length > 0 && ( + · {c.files.length} file{c.files.length !== 1 ? 's' : ''} + )} + {branchLabel && ( + {branchLabel} + )} +
+
+
+ ); + })}
- ))} -
- )} + )} - {/* Pagination */} - {!loading && !error && (commits.length > 0 || page > 1) && ( -
-
+ )}
- )} +
setViewingHash(null)} />