From a5310dcc12ba16f6132d63103f42758de8e1db3d Mon Sep 17 00:00:00 2001 From: sahalterion Date: Fri, 3 Apr 2026 15:42:55 -0700 Subject: [PATCH] feat(web): add group view mode with multi-repo graph visualization Server: - Add /api/groups, /api/group-graph, /api/group-status endpoints - /api/group-graph returns merged graph with namespaced node IDs and synthetic CROSS_REPO_IMPORT edges from contracts.json Web UI: - Add "Group" toggle button in Header with group selection dropdown - Add group mode state (groupMode, activeGroup, connectToGroup) - Add CROSS_REPO_IMPORT edge type with amber color (#f59e0b) - Add REPO_COLORS palette for multi-repo visual distinction - Graph adapter detects multi-repo mode from _repo property or namespaced IDs, positions each repo's nodes in separate regions Backend client: - Add fetchGroups(), fetchGroupGraph(), fetchGroupStatus() - Add GroupGraphResult and GroupStatus types Includes 15 web UI tests covering constants, graph adapter multi-repo behavior, and type correctness. All 213 web tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus-web/src/components/Header.tsx | 63 +++++ gitnexus-web/src/hooks/useAppState.tsx | 100 ++++++++ gitnexus-web/src/lib/constants.ts | 28 ++- gitnexus-web/src/lib/graph-adapter.ts | 69 +++++- gitnexus-web/src/services/backend-client.ts | 52 +++++ gitnexus-web/test/unit/group-features.test.ts | 219 ++++++++++++++++++ gitnexus/src/server/api.ts | 131 +++++++++++ 7 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 gitnexus-web/test/unit/group-features.test.ts diff --git a/gitnexus-web/src/components/Header.tsx b/gitnexus-web/src/components/Header.tsx index 3061a2e4a..060e951e5 100644 --- a/gitnexus-web/src/components/Header.tsx +++ b/gitnexus-web/src/components/Header.tsx @@ -15,6 +15,7 @@ import { useAppState } from '../hooks/useAppState'; import { deleteRepo, fetchRepos, + fetchGroups, startAnalyze, streamAnalyzeProgress, type BackendRepo, @@ -63,6 +64,14 @@ export const Header = ({ rightPanelTab, setSettingsPanelOpen, setHelpDialogBoxOpen, + groupMode, + activeGroup, + availableGroups, + setAvailableGroups, + connectToGroup, + setGroupMode, + setActiveGroup, + serverBaseUrl, } = useAppState(); const [searchQuery, setSearchQuery] = useState(''); const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false); @@ -72,6 +81,7 @@ export const Header = ({ const reanalyzeSseRef = useRef(null); const repoDropdownRef = useRef(null); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const searchRef = useRef(null); const inputRef = useRef(null); @@ -369,6 +379,59 @@ export const Header = ({ )} )} + + {/* Group View toggle */} + {serverBaseUrl && ( +
+ + {isGroupDropdownOpen && !groupMode && availableGroups.length > 0 && ( +
+
Select a group
+ {availableGroups.map((g) => ( + + ))} +
+ )} +
+ )} {/* Center - Search */} diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 25c57767f..d3a83e4d4 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -147,6 +147,15 @@ interface AppState { switchRepo: (repoName: string) => Promise; setCurrentRepo: (repoName: string) => void; + // Group mode (multi-repo combined graph) + groupMode: boolean; + setGroupMode: (mode: boolean) => void; + activeGroup: string | null; + setActiveGroup: (name: string | null) => void; + availableGroups: string[]; + setAvailableGroups: (groups: string[]) => void; + connectToGroup: (groupName: string) => Promise; + // Worker API (shared across app) runQuery: (cypher: string) => Promise; isDatabaseReady: () => Promise; @@ -319,6 +328,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const [serverBaseUrl, setServerBaseUrl] = useState(null); const [availableRepos, setAvailableRepos] = useState([]); + // Group mode (multi-repo combined graph) + const [groupMode, setGroupMode] = useState(false); + const [activeGroup, setActiveGroup] = useState(null); + const [availableGroups, setAvailableGroups] = useState([]); + // Embedding state const [embeddingStatus, setEmbeddingStatus] = useState('idle'); const [embeddingProgress, setEmbeddingProgress] = useState<{ @@ -1172,6 +1186,84 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { ], ); + const connectToGroup = useCallback( + async (groupName: string) => { + if (!serverBaseUrl) return; + + setProgress({ + phase: 'extracting', + percent: 10, + message: `Loading group "${groupName}"...`, + }); + setViewMode('loading'); + + // Clear stale state + setHighlightedNodeIds(new Set()); + clearAIToolHighlights(); + clearAICitationHighlights(); + clearBlastRadius(); + setSelectedNode(null); + setQueryResult(null); + setCodeReferences([]); + setCodePanelOpen(false); + setCodeReferenceFocus(null); + + try { + const { fetchGroupGraph } = await import('../services/backend-client'); + const result = await fetchGroupGraph(groupName); + + setProgress({ phase: 'structure', percent: 60, message: 'Building combined graph...' }); + + const { createKnowledgeGraph } = await import('../core/graph/graph'); + const newGraph = createKnowledgeGraph(); + + for (const node of result.nodes) { + newGraph.addNode(node); + } + for (const rel of result.relationships) { + newGraph.addRelationship(rel); + } + + setGraph(newGraph); + setGroupMode(true); + setActiveGroup(groupName); + setProjectName(`Group: ${groupName}`); + + setProgress(null); + setViewMode('exploring'); + } catch (err) { + setProgress({ + phase: 'error', + percent: 0, + message: 'Failed to load group', + detail: err instanceof Error ? err.message : 'Unknown error', + }); + setTimeout(() => { + setViewMode('exploring'); + setProgress(null); + }, 3000); + } + }, + [ + serverBaseUrl, + setProgress, + setViewMode, + setProjectName, + setGraph, + setHighlightedNodeIds, + clearAIToolHighlights, + clearAICitationHighlights, + clearBlastRadius, + setSelectedNode, + setQueryResult, + setCodeReferences, + setCodePanelOpen, + setCodeReferenceFocus, + setGroupMode, + setActiveGroup, + ], + ); + const removeCodeReference = useCallback( (id: string) => { setCodeReferences((prev) => { @@ -1256,6 +1348,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setAvailableRepos, switchRepo, setCurrentRepo, + // Group mode + groupMode, + setGroupMode, + activeGroup, + setActiveGroup, + availableGroups, + setAvailableGroups, + connectToGroup, runQuery, isDatabaseReady, // Embedding state and methods diff --git a/gitnexus-web/src/lib/constants.ts b/gitnexus-web/src/lib/constants.ts index f6804405b..be879201e 100644 --- a/gitnexus-web/src/lib/constants.ts +++ b/gitnexus-web/src/lib/constants.ts @@ -132,7 +132,14 @@ export const FILTERABLE_LABELS: NodeLabel[] = [ ]; // Edge/Relation types -export type EdgeType = 'CONTAINS' | 'DEFINES' | 'IMPORTS' | 'CALLS' | 'EXTENDS' | 'IMPLEMENTS'; +export type EdgeType = + | 'CONTAINS' + | 'DEFINES' + | 'IMPORTS' + | 'CALLS' + | 'EXTENDS' + | 'IMPLEMENTS' + | 'CROSS_REPO_IMPORT'; export const ALL_EDGE_TYPES: EdgeType[] = [ 'CONTAINS', @@ -141,6 +148,7 @@ export const ALL_EDGE_TYPES: EdgeType[] = [ 'CALLS', 'EXTENDS', 'IMPLEMENTS', + 'CROSS_REPO_IMPORT', ]; // Default visible edges (CALLS hidden by default to reduce clutter) @@ -151,6 +159,7 @@ export const DEFAULT_VISIBLE_EDGES: EdgeType[] = [ 'EXTENDS', 'IMPLEMENTS', 'CALLS', + 'CROSS_REPO_IMPORT', ]; // Edge display info for UI @@ -161,4 +170,21 @@ export const EDGE_INFO: Record = { CALLS: { color: '#7c3aed', label: 'Calls' }, EXTENDS: { color: '#c2410c', label: 'Extends' }, IMPLEMENTS: { color: '#be185d', label: 'Implements' }, + CROSS_REPO_IMPORT: { color: '#f59e0b', label: 'Cross-Repo Import' }, +}; + +// Repo color palette for multi-repo graph view +export const REPO_COLORS = [ + '#3b82f6', // blue + '#10b981', // emerald + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // violet + '#06b6d4', // cyan + '#ec4899', // pink + '#84cc16', // lime +]; + +export const getRepoColor = (repoIndex: number): string => { + return REPO_COLORS[repoIndex % REPO_COLORS.length]; }; diff --git a/gitnexus-web/src/lib/graph-adapter.ts b/gitnexus-web/src/lib/graph-adapter.ts index 03caf8c92..55059a1ec 100644 --- a/gitnexus-web/src/lib/graph-adapter.ts +++ b/gitnexus-web/src/lib/graph-adapter.ts @@ -1,7 +1,7 @@ import Graph from 'graphology'; import type { NodeLabel } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../core/graph/types'; -import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { NODE_COLORS, NODE_SIZES, getCommunityColor, getRepoColor } from './constants'; export interface SigmaNodeAttributes { x: number; @@ -19,6 +19,8 @@ export interface SigmaNodeAttributes { mass?: number; // ForceAtlas2 mass - higher = more repulsion community?: number; // Community index from Leiden algorithm communityColor?: string; // Color assigned by community + repoName?: string; // Repo name for multi-repo graph view + repoColor?: string; // Color tint for repo identification } export interface SigmaEdgeAttributes { @@ -88,6 +90,36 @@ export const knowledgeGraphToGraphology = ( const graph = new Graph(); const nodeCount = knowledgeGraph.nodes.length; + // Detect multi-repo mode: nodes with _repo property or namespaced IDs (repoName::nodeId) + const repoSet = new Set(); + for (const node of knowledgeGraph.nodes) { + const repo = (node.properties as Record)._repo as string | undefined; + if (repo) { + repoSet.add(repo); + } else { + const sep = node.id.indexOf('::'); + if (sep > 0) repoSet.add(node.id.substring(0, sep)); + } + } + const isMultiRepo = repoSet.size > 1; + const repoList = [...repoSet]; + + // Build repo center positions for multi-repo layout + const repoCenters = new Map(); + if (isMultiRepo) { + const repoSpread = Math.sqrt(nodeCount) * 60; + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + repoList.forEach((repo, idx) => { + const angle = idx * goldenAngle; + const radius = repoSpread * 0.6; + repoCenters.set(repo, { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + color: getRepoColor(idx), + }); + }); + } + // Build parent-child map from hierarchy relationships // CONTAINS: Folder -> File // DEFINES: File -> Function/Class/Interface/Method @@ -151,6 +183,14 @@ export const knowledgeGraphToGraphology = ( // Store positions for parent lookup const nodePositions = new Map(); + // Helper to get repo name from a node + const getNodeRepo = (node: typeof knowledgeGraph.nodes[0]): string | undefined => { + const repo = (node.properties as Record)._repo as string | undefined; + if (repo) return repo; + const sep = node.id.indexOf('::'); + return sep > 0 ? node.id.substring(0, sep) : undefined; + }; + // Position structural nodes (folders, etc.) in a wide radial pattern FIRST structuralNodes.forEach((node, index) => { // Use golden angle for even distribution @@ -160,8 +200,16 @@ export const knowledgeGraphToGraphology = ( // Add some randomness to prevent perfect patterns const jitter = structuralSpread * 0.15; - const x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter; - const y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter; + let x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter; + let y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter; + + // In multi-repo mode, offset structural nodes to their repo's region + const nodeRepo = getNodeRepo(node); + const repoCenter = nodeRepo ? repoCenters.get(nodeRepo) : undefined; + if (repoCenter) { + x += repoCenter.x; + y += repoCenter.y; + } nodePositions.set(node.id, { x, y }); @@ -181,6 +229,8 @@ export const knowledgeGraphToGraphology = ( endLine: node.properties.endLine, hidden: false, mass: getNodeMass(node.label, nodeCount), + repoName: nodeRepo, + repoColor: repoCenter?.color, }); }); @@ -218,6 +268,14 @@ export const knowledgeGraphToGraphology = ( } } + // In multi-repo mode, offset orphan/non-structural nodes to their repo's region + const nodeRepo = getNodeRepo(node); + const repoCenter = nodeRepo ? repoCenters.get(nodeRepo) : undefined; + if (repoCenter && !childToParent.has(nodeId) && !clusterCenter) { + x += repoCenter.x; + y += repoCenter.y; + } + nodePositions.set(nodeId, { x, y }); const baseSize = NODE_SIZES[node.label] || 8; @@ -246,6 +304,8 @@ export const knowledgeGraphToGraphology = ( mass: getNodeMass(node.label, nodeCount), community: communityIndex, communityColor: hasCommunity ? getCommunityColor(communityIndex!) : undefined, + repoName: nodeRepo, + repoColor: repoCenter?.color, }); }; @@ -295,6 +355,9 @@ export const knowledgeGraphToGraphology = ( // TYPE RELATIONSHIPS - Warm colors (OOP) EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation + + // CROSS-REPO - Amber (stands out against everything) + CROSS_REPO_IMPORT: { color: '#f59e0b', sizeMultiplier: 1.5 }, // Amber - cross-repo link }; knowledgeGraph.relationships.forEach((rel) => { diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index ebc18c17d..ad611411c 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -774,3 +774,55 @@ export async function connectToServer( return { nodes, relationships, repoInfo }; } + +// ── Group API ────────────────────────────────────────────────────────────── + +export interface GroupGraphResult { + repos: Array<{ name: string; groupPath: string; nodeCount: number; edgeCount: number }>; + nodes: GraphNode[]; + relationships: GraphRelationship[]; + crossLinks: Array<{ + from: { repo: string; symbolUid: string; symbolRef: { filePath: string; name: string } }; + to: { repo: string; symbolUid: string; symbolRef: { filePath: string; name: string } }; + type: string; + contractId: string; + matchType: string; + confidence: number; + }>; +} + +export interface GroupStatus { + group: string; + lastSync: string | null; + missingRepos: string[]; + repos: Record< + string, + { indexStale: boolean; contractsStale: boolean; missing: boolean; commitsBehind?: number } + >; +} + +/** Fetch list of configured group names. */ +export const fetchGroups = async (): Promise => { + const response = await fetchWithTimeout(`${_backendUrl}/api/groups`); + await assertOk(response); + return response.json() as Promise; +}; + +/** Fetch merged graph for a group (all repos + cross-repo edges). */ +export const fetchGroupGraph = async ( + groupName: string, + signal?: AbortSignal, +): Promise => { + const url = `${_backendUrl}/api/group-graph?group=${encodeURIComponent(groupName)}`; + const response = await fetchWithTimeout(url, { signal }, 120_000); + await assertOk(response); + return response.json() as Promise; +}; + +/** Fetch group status (staleness info). */ +export const fetchGroupStatus = async (groupName: string): Promise => { + const url = `${_backendUrl}/api/group-status?group=${encodeURIComponent(groupName)}`; + const response = await fetchWithTimeout(url); + await assertOk(response); + return response.json() as Promise; +}; diff --git a/gitnexus-web/test/unit/group-features.test.ts b/gitnexus-web/test/unit/group-features.test.ts new file mode 100644 index 000000000..1fa315613 --- /dev/null +++ b/gitnexus-web/test/unit/group-features.test.ts @@ -0,0 +1,219 @@ +/** + * Tests for multi-repo group features added to the web UI. + * Covers: backend client group functions, constants updates, graph adapter repo-awareness. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ALL_EDGE_TYPES, + DEFAULT_VISIBLE_EDGES, + EDGE_INFO, + REPO_COLORS, + getRepoColor, + type EdgeType, +} from '../../src/lib/constants'; +import { + knowledgeGraphToGraphology, + type SigmaNodeAttributes, +} from '../../src/lib/graph-adapter'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../src/core/graph/types'; + +// ── Constants tests ──────────────────────────────────────────────────────── + +describe('CROSS_REPO_IMPORT edge type', () => { + it('is included in ALL_EDGE_TYPES', () => { + expect(ALL_EDGE_TYPES).toContain('CROSS_REPO_IMPORT'); + }); + + it('is included in DEFAULT_VISIBLE_EDGES', () => { + expect(DEFAULT_VISIBLE_EDGES).toContain('CROSS_REPO_IMPORT'); + }); + + it('has EDGE_INFO entry with amber color', () => { + const info = EDGE_INFO['CROSS_REPO_IMPORT' as EdgeType]; + expect(info).toBeDefined(); + expect(info.color).toBe('#f59e0b'); + expect(info.label).toBe('Cross-Repo Import'); + }); +}); + +describe('REPO_COLORS', () => { + it('has at least 4 colors', () => { + expect(REPO_COLORS.length).toBeGreaterThanOrEqual(4); + }); + + it('returns valid hex colors', () => { + for (const color of REPO_COLORS) { + expect(color).toMatch(/^#[0-9a-f]{6}$/i); + } + }); +}); + +describe('getRepoColor', () => { + it('returns valid hex color for any index', () => { + for (let i = 0; i < 20; i++) { + expect(getRepoColor(i)).toMatch(/^#[0-9a-f]{6}$/i); + } + }); + + it('wraps around when index exceeds palette length', () => { + const color0 = getRepoColor(0); + const colorWrapped = getRepoColor(REPO_COLORS.length); + expect(color0).toBe(colorWrapped); + }); +}); + +// ── Graph adapter tests ──────────────────────────────────────────────────── + +function makeNode(id: string, label: string, name: string, props?: Record): GraphNode { + return { + id, + label: label as GraphNode['label'], + properties: { + name, + filePath: `src/${name}.ts`, + ...props, + } as GraphNode['properties'], + }; +} + +function makeRel(sourceId: string, targetId: string, type: string): GraphRelationship { + return { + id: `${sourceId}_${type}_${targetId}`, + sourceId, + targetId, + type: type as GraphRelationship['type'], + confidence: 1.0, + reason: 'test', + }; +} + +function makeGraph(nodes: GraphNode[], relationships: GraphRelationship[]): KnowledgeGraph { + return { + nodes, + relationships, + get nodeCount() { return nodes.length; }, + get relationshipCount() { return relationships.length; }, + addNode: () => {}, + addRelationship: () => {}, + }; +} + +describe('knowledgeGraphToGraphology - multi-repo support', () => { + it('detects multi-repo mode from _repo property', () => { + const nodes = [ + makeNode('repoA::fn1', 'Function', 'funcA', { _repo: 'repoA' }), + makeNode('repoB::fn1', 'Function', 'funcB', { _repo: 'repoB' }), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, [])); + + // Both nodes should exist with repoName attribute + const attrA = graph.getNodeAttributes('repoA::fn1') as SigmaNodeAttributes; + const attrB = graph.getNodeAttributes('repoB::fn1') as SigmaNodeAttributes; + expect(attrA.repoName).toBe('repoA'); + expect(attrB.repoName).toBe('repoB'); + }); + + it('detects multi-repo mode from namespaced IDs', () => { + const nodes = [ + makeNode('repoA::fn1', 'Function', 'funcA'), + makeNode('repoB::fn1', 'Function', 'funcB'), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, [])); + + const attrA = graph.getNodeAttributes('repoA::fn1') as SigmaNodeAttributes; + expect(attrA.repoName).toBe('repoA'); + }); + + it('assigns different repo colors to nodes in different repos', () => { + const nodes = [ + makeNode('repoA::fn1', 'Function', 'funcA', { _repo: 'repoA' }), + makeNode('repoB::fn1', 'Function', 'funcB', { _repo: 'repoB' }), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, [])); + + const attrA = graph.getNodeAttributes('repoA::fn1') as SigmaNodeAttributes; + const attrB = graph.getNodeAttributes('repoB::fn1') as SigmaNodeAttributes; + expect(attrA.repoColor).toBeDefined(); + expect(attrB.repoColor).toBeDefined(); + expect(attrA.repoColor).not.toBe(attrB.repoColor); + }); + + it('handles CROSS_REPO_IMPORT edges', () => { + const nodes = [ + makeNode('repoA::fn1', 'Function', 'funcA', { _repo: 'repoA' }), + makeNode('repoB::fn1', 'Function', 'funcB', { _repo: 'repoB' }), + ]; + const rels = [ + makeRel('repoB::fn1', 'repoA::fn1', 'CROSS_REPO_IMPORT'), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, rels)); + + // Edge should exist + expect(graph.hasEdge('repoB::fn1', 'repoA::fn1')).toBe(true); + const edgeKey = graph.edge('repoB::fn1', 'repoA::fn1'); + const edgeAttr = graph.getEdgeAttributes(edgeKey!); + expect(edgeAttr.relationType).toBe('CROSS_REPO_IMPORT'); + expect(edgeAttr.color).toBe('#f59e0b'); // Amber + }); + + it('works correctly in single-repo mode (no repoName assigned)', () => { + const nodes = [ + makeNode('fn1', 'Function', 'funcA'), + makeNode('fn2', 'Function', 'funcB'), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, [])); + + const attr = graph.getNodeAttributes('fn1') as SigmaNodeAttributes; + expect(attr.repoName).toBeUndefined(); + expect(attr.repoColor).toBeUndefined(); + }); + + it('positions multi-repo nodes in separate regions', () => { + const nodes = [ + makeNode('repoA::folder1', 'Folder', 'src', { _repo: 'repoA' }), + makeNode('repoB::folder1', 'Folder', 'src', { _repo: 'repoB' }), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, [])); + + const posA = graph.getNodeAttributes('repoA::folder1') as SigmaNodeAttributes; + const posB = graph.getNodeAttributes('repoB::folder1') as SigmaNodeAttributes; + + // Different repos should be positioned in different regions + const distance = Math.sqrt( + Math.pow(posA.x - posB.x, 2) + Math.pow(posA.y - posB.y, 2), + ); + expect(distance).toBeGreaterThan(0); + }); +}); + +// ── Backend client type tests ────────────────────────────────────────────── + +describe('backend-client group types', () => { + it('GroupGraphResult has expected shape', async () => { + // Type-level test: ensure the interface is importable and has the right fields + const { type } = await import('../../src/services/backend-client'); + // This is a compile-time check — if GroupGraphResult type is wrong, TS will fail + const mockResult: import('../../src/services/backend-client').GroupGraphResult = { + repos: [{ name: 'test', groupPath: 'test', nodeCount: 10, edgeCount: 5 }], + nodes: [], + relationships: [], + crossLinks: [], + }; + expect(mockResult.repos).toHaveLength(1); + expect(mockResult.nodes).toHaveLength(0); + }); + + it('GroupStatus has expected shape', () => { + const mockStatus: import('../../src/services/backend-client').GroupStatus = { + group: 'test', + lastSync: '2026-04-01T00:00:00Z', + missingRepos: [], + repos: { + myRepo: { indexStale: false, contractsStale: false, missing: false }, + }, + }; + expect(mockStatus.group).toBe('test'); + expect(mockStatus.repos.myRepo.indexStale).toBe(false); + }); +}); diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index d9aa00ae6..855ca6948 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -716,6 +716,137 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => } }); + // List all configured groups + app.get('/api/groups', async (_req, res) => { + try { + const { listGroups } = await import('../core/group/storage.js'); + const groups = await listGroups(); + res.json(groups); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to list groups' }); + } + }); + + // Get merged graph for a group (all repos + cross-repo edges) + app.get('/api/group-graph', async (req, res) => { + try { + const groupName = (req.query.group as string) || ''; + if (!groupName) { + res.status(400).json({ error: 'Missing "group" query parameter' }); + return; + } + + const { getGroupDir, getDefaultGitnexusDir, readContractRegistry } = + await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + + const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); + const config = await loadGroupConfig(groupDir); + const registry = await readContractRegistry(groupDir); + + const allNodes: GraphNode[] = []; + const allRelationships: GraphRelationship[] = []; + const repoSummaries: Array<{ name: string; groupPath: string; nodeCount: number; edgeCount: number }> = []; + + // Load graph from each repo and namespace node IDs + for (const [groupPath, registryName] of Object.entries(config.repos)) { + const entry = await resolveRepo(registryName); + if (!entry) continue; + + const lbugPath = path.join(entry.storagePath, 'lbug'); + try { + const graph = await withLbugDb(lbugPath, async () => buildGraph(false)); + + // Namespace all node IDs with repo name + const prefix = `${registryName}::`; + for (const node of graph.nodes) { + allNodes.push({ + ...node, + id: `${prefix}${node.id}`, + properties: { + ...node.properties, + _repo: registryName, + _groupPath: groupPath, + } as GraphNode['properties'], + }); + } + + for (const rel of graph.relationships) { + allRelationships.push({ + ...rel, + id: `${prefix}${rel.id}`, + sourceId: `${prefix}${rel.sourceId}`, + targetId: `${prefix}${rel.targetId}`, + }); + } + + repoSummaries.push({ + name: registryName, + groupPath, + nodeCount: graph.nodes.length, + edgeCount: graph.relationships.length, + }); + } catch { + // Skip inaccessible repos + } + } + + // Add cross-repo edges from contracts.json + if (registry) { + for (const link of registry.crossLinks) { + const fromRepoName = + config.repos[link.from.repo] || link.from.repo; + const toRepoName = + config.repos[link.to.repo] || link.to.repo; + + allRelationships.push({ + id: `cross::${link.contractId}::${fromRepoName}::${toRepoName}`, + type: 'CROSS_REPO_IMPORT' as GraphRelationship['type'], + sourceId: link.from.symbolUid + ? `${fromRepoName}::${link.from.symbolUid}` + : `${fromRepoName}::cross_ref::${link.contractId}`, + targetId: link.to.symbolUid + ? `${toRepoName}::${link.to.symbolUid}` + : `${toRepoName}::cross_ref::${link.contractId}`, + confidence: link.confidence, + reason: `${link.type} contract: ${link.contractId} (${link.matchType})`, + }); + } + } + + res.json({ + repos: repoSummaries, + nodes: allNodes, + relationships: allRelationships, + crossLinks: registry?.crossLinks || [], + }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to build group graph' }); + } + }); + + // Get group status (staleness info) + app.get('/api/group-status', async (req, res) => { + try { + const groupName = (req.query.group as string) || ''; + if (!groupName) { + res.status(400).json({ error: 'Missing "group" query parameter' }); + return; + } + + const backend = new LocalBackend(); + try { + await backend.init(); + const result = await backend.getGroupService().groupStatus({ name: groupName }); + res.json(result); + } finally { + await backend.dispose().catch(() => {}); + } + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get group status' }); + } + }); + // Get repo info app.get('/api/repo', async (req, res) => { try {