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) <noreply@anthropic.com>
This commit is contained in:
sahalterion 2026-04-03 15:42:55 -07:00
parent b59493932d
commit a5310dcc12
7 changed files with 658 additions and 4 deletions

View file

@ -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<AbortController | null>(null);
const repoDropdownRef = useRef<HTMLDivElement>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const searchRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@ -369,6 +379,59 @@ export const Header = ({
)}
</div>
)}
{/* Group View toggle */}
{serverBaseUrl && (
<div className="relative">
<button
onClick={async () => {
if (groupMode) {
setGroupMode(false);
setActiveGroup(null);
} else {
try {
const groups = await fetchGroups();
setAvailableGroups(groups);
if (groups.length === 1) {
connectToGroup(groups[0]);
} else if (groups.length > 1) {
setIsGroupDropdownOpen(true);
}
} catch {
// No groups available
}
}
}}
className={`flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-all ${
groupMode
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-border-subtle bg-surface text-text-muted hover:border-border-default hover:text-text-secondary'
}`}
title={groupMode ? `Group: ${activeGroup}` : 'Multi-repo group view'}
>
<FolderOpen className="h-3 w-3" />
<span>{groupMode ? activeGroup : 'Group'}</span>
</button>
{isGroupDropdownOpen && !groupMode && availableGroups.length > 0 && (
<div className="absolute top-full left-0 z-50 mt-1.5 w-56 animate-slide-up overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
<div className="px-3 py-2 text-xs font-medium text-text-muted">Select a group</div>
{availableGroups.map((g) => (
<button
key={g}
onClick={() => {
setIsGroupDropdownOpen(false);
connectToGroup(g);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-text-secondary transition-colors hover:bg-hover"
>
<FolderOpen className="h-3.5 w-3.5 text-amber-400" />
{g}
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Center - Search */}

View file

@ -147,6 +147,15 @@ interface AppState {
switchRepo: (repoName: string) => Promise<void>;
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<void>;
// Worker API (shared across app)
runQuery: (cypher: string) => Promise<any[]>;
isDatabaseReady: () => Promise<boolean>;
@ -319,6 +328,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const [serverBaseUrl, setServerBaseUrl] = useState<string | null>(null);
const [availableRepos, setAvailableRepos] = useState<BackendRepo[]>([]);
// Group mode (multi-repo combined graph)
const [groupMode, setGroupMode] = useState(false);
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const [availableGroups, setAvailableGroups] = useState<string[]>([]);
// Embedding state
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('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

View file

@ -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<EdgeType, { color: string; label: string }> = {
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];
};

View file

@ -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<SigmaNodeAttributes, SigmaEdgeAttributes>();
const nodeCount = knowledgeGraph.nodes.length;
// Detect multi-repo mode: nodes with _repo property or namespaced IDs (repoName::nodeId)
const repoSet = new Set<string>();
for (const node of knowledgeGraph.nodes) {
const repo = (node.properties as Record<string, unknown>)._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<string, { x: number; y: number; color: string }>();
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<string, { x: number; y: number }>();
// Helper to get repo name from a node
const getNodeRepo = (node: typeof knowledgeGraph.nodes[0]): string | undefined => {
const repo = (node.properties as Record<string, unknown>)._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) => {

View file

@ -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<string[]> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/groups`);
await assertOk(response);
return response.json() as Promise<string[]>;
};
/** Fetch merged graph for a group (all repos + cross-repo edges). */
export const fetchGroupGraph = async (
groupName: string,
signal?: AbortSignal,
): Promise<GroupGraphResult> => {
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<GroupGraphResult>;
};
/** Fetch group status (staleness info). */
export const fetchGroupStatus = async (groupName: string): Promise<GroupStatus> => {
const url = `${_backendUrl}/api/group-status?group=${encodeURIComponent(groupName)}`;
const response = await fetchWithTimeout(url);
await assertOk(response);
return response.json() as Promise<GroupStatus>;
};

View file

@ -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<string, unknown>): 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);
});
});

View file

@ -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 {