mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
b59493932d
commit
a5310dcc12
7 changed files with 658 additions and 4 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
219
gitnexus-web/test/unit/group-features.test.ts
Normal file
219
gitnexus-web/test/unit/group-features.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue