mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-13 08:51:28 +00:00
refactor(serve): rewrite API server to use LocalBackend for multi-repo support
Replace direct single-repo KuzuDB access with LocalBackend, the same backend the MCP server uses. The serve command now works with ALL indexed repos via the global registry instead of only the CWD repo. New/updated endpoints: - GET /api/repos (list all indexed repos) - GET /api/repo?name=X (repo metadata) - GET /api/graph?repo=X (full knowledge graph) - POST /api/query (raw Cypher via backend) - POST /api/search (process-grouped search via backend) - GET /api/file?repo=X&path=Y (file read with path traversal protection) - GET /api/processes?repo=X, /api/process?repo=X&name=Y - GET /api/clusters?repo=X, /api/cluster?repo=X&name=Y
This commit is contained in:
parent
28339c995d
commit
8e8bb90fe4
1 changed files with 224 additions and 79 deletions
|
|
@ -1,24 +1,29 @@
|
|||
/**
|
||||
* HTTP API Server
|
||||
*
|
||||
* REST API for browser-based clients to query the local .gitnexus/ index.
|
||||
* HTTP API Server (Multi-Repo)
|
||||
*
|
||||
* REST API for browser-based clients to query indexed repositories.
|
||||
* Uses LocalBackend for multi-repo support via the global registry —
|
||||
* the same backend the MCP server uses.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { findRepo, loadMeta } from '../storage/repo-manager.js';
|
||||
import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { NODE_TABLES } from '../core/kuzu/schema.js';
|
||||
import { GraphNode, GraphRelationship } from '../core/graph/types.js';
|
||||
import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
|
||||
import { hybridSearch } from '../core/search/hybrid-search.js';
|
||||
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
|
||||
import { isEmbedderReady } from '../core/embeddings/embedder.js';
|
||||
|
||||
const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
/**
|
||||
* Build the full knowledge graph for a repo by querying each node table
|
||||
* and all relationships via the backend's cypher tool.
|
||||
*/
|
||||
const buildGraph = async (
|
||||
backend: LocalBackend,
|
||||
repoName: string,
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
const nodes: GraphNode[] = [];
|
||||
|
||||
for (const table of NODE_TABLES) {
|
||||
try {
|
||||
let query = '';
|
||||
|
|
@ -34,7 +39,10 @@ const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphR
|
|||
query = `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`;
|
||||
}
|
||||
|
||||
const rows = await executeQuery(query);
|
||||
const result = await backend.callTool('cypher', { repo: repoName, query });
|
||||
// cypher returns the rows directly (array), or { error } on failure
|
||||
const rows = Array.isArray(result) ? result : [];
|
||||
|
||||
for (const row of rows) {
|
||||
nodes.push({
|
||||
id: row.id ?? row[0],
|
||||
|
|
@ -62,108 +70,245 @@ const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphR
|
|||
}
|
||||
|
||||
const relationships: GraphRelationship[] = [];
|
||||
const relRows = await executeQuery(
|
||||
`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`
|
||||
);
|
||||
for (const row of relRows) {
|
||||
relationships.push({
|
||||
id: `${row.sourceId}_${row.type}_${row.targetId}`,
|
||||
type: row.type,
|
||||
sourceId: row.sourceId,
|
||||
targetId: row.targetId,
|
||||
confidence: row.confidence,
|
||||
reason: row.reason,
|
||||
step: row.step,
|
||||
try {
|
||||
const relResult = await backend.callTool('cypher', {
|
||||
repo: repoName,
|
||||
query: `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`,
|
||||
});
|
||||
const relRows = Array.isArray(relResult) ? relResult : [];
|
||||
|
||||
for (const row of relRows) {
|
||||
relationships.push({
|
||||
id: `${row.sourceId}_${row.type}_${row.targetId}`,
|
||||
type: row.type,
|
||||
sourceId: row.sourceId,
|
||||
targetId: row.targetId,
|
||||
confidence: row.confidence,
|
||||
reason: row.reason,
|
||||
step: row.step,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore relationship query failures
|
||||
}
|
||||
|
||||
return { nodes, relationships };
|
||||
};
|
||||
|
||||
export const createServer = async (port: number) => {
|
||||
const backend = new LocalBackend();
|
||||
const hasRepos = await backend.init();
|
||||
|
||||
if (!hasRepos) {
|
||||
console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.');
|
||||
console.warn('Run "gitnexus analyze" in a repository to index it first.');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Get repo info
|
||||
app.get('/api/repo', async (_req, res) => {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
res.status(404).json({ error: 'Repository not indexed. Run: gitnexus analyze' });
|
||||
return;
|
||||
// ─── GET /api/repos ─────────────────────────────────────────────
|
||||
// List all indexed repositories
|
||||
app.get('/api/repos', async (_req, res) => {
|
||||
try {
|
||||
const repos = await backend.listRepos();
|
||||
res.json(repos);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to list repos' });
|
||||
}
|
||||
res.json({
|
||||
repoPath: repo.repoPath,
|
||||
indexedAt: repo.meta.indexedAt,
|
||||
stats: repo.meta.stats || {},
|
||||
});
|
||||
});
|
||||
|
||||
// Get full graph
|
||||
app.get('/api/graph', async (_req, res) => {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
res.status(404).json({ error: 'Repository not indexed' });
|
||||
return;
|
||||
// ─── GET /api/repo?name=X ──────────────────────────────────────
|
||||
// Get metadata for a specific repo
|
||||
app.get('/api/repo', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.name as string | undefined;
|
||||
const repo = await backend.resolveRepo(repoName);
|
||||
res.json({
|
||||
name: repo.name,
|
||||
repoPath: repo.repoPath,
|
||||
indexedAt: repo.indexedAt,
|
||||
lastCommit: repo.lastCommit,
|
||||
stats: repo.stats || {},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(404).json({ error: err.message || 'Repository not found' });
|
||||
}
|
||||
await initKuzu(repo.kuzuPath);
|
||||
const graph = await buildGraph();
|
||||
res.json(graph);
|
||||
});
|
||||
|
||||
// Execute Cypher query
|
||||
// ─── GET /api/graph?repo=X ─────────────────────────────────────
|
||||
// Full knowledge graph (all nodes + relationships)
|
||||
app.get('/api/graph', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
// Resolve repo to validate it exists and get the name
|
||||
const repo = await backend.resolveRepo(repoName);
|
||||
const graph = await buildGraph(backend, repo.name);
|
||||
res.json(graph);
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Failed to build graph' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/query ───────────────────────────────────────────
|
||||
// Execute a raw Cypher query
|
||||
app.post('/api/query', async (req, res) => {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
res.status(404).json({ error: 'Repository not indexed' });
|
||||
return;
|
||||
try {
|
||||
const repoName = (req.body.repo ?? req.query.repo) as string | undefined;
|
||||
const cypher = req.body.cypher as string;
|
||||
|
||||
if (!cypher) {
|
||||
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backend.callTool('cypher', { repo: repoName, query: cypher });
|
||||
res.json({ result });
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Query failed' });
|
||||
}
|
||||
await initKuzu(repo.kuzuPath);
|
||||
const result = await executeQuery(req.body.cypher);
|
||||
res.json({ result });
|
||||
});
|
||||
|
||||
// Search
|
||||
// ─── POST /api/search ──────────────────────────────────────────
|
||||
// Process-grouped semantic search
|
||||
app.post('/api/search', async (req, res) => {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
res.status(404).json({ error: 'Repository not indexed' });
|
||||
return;
|
||||
}
|
||||
await initKuzu(repo.kuzuPath);
|
||||
try {
|
||||
const repoName = (req.body.repo ?? req.query.repo) as string | undefined;
|
||||
const query = req.body.query as string;
|
||||
const limit = req.body.limit as number | undefined;
|
||||
|
||||
const query = req.body.query ?? '';
|
||||
const limit = req.body.limit ?? 10;
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Missing "query" in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmbedderReady()) {
|
||||
const results = await hybridSearch(query, limit, executeQuery, semanticSearch);
|
||||
const results = await backend.callTool('query', {
|
||||
repo: repoName,
|
||||
query,
|
||||
limit,
|
||||
});
|
||||
res.json({ results });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Search failed' });
|
||||
}
|
||||
|
||||
// FTS-only fallback when embeddings aren't loaded
|
||||
const results = await searchFTSFromKuzu(query, limit);
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
// Read file
|
||||
// ─── GET /api/file?repo=X&path=Y ──────────────────────────────
|
||||
// Read a file from a resolved repo path on disk
|
||||
app.get('/api/file', async (req, res) => {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
res.status(404).json({ error: 'Repository not indexed' });
|
||||
return;
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const filePath = req.query.path as string;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing "path" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = await backend.resolveRepo(repoName);
|
||||
|
||||
// Resolve the full path and validate it stays within the repo root
|
||||
const repoRoot = path.resolve(repo.repoPath);
|
||||
const fullPath = path.resolve(repoRoot, filePath);
|
||||
|
||||
if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
|
||||
res.status(403).json({ error: 'Path traversal denied: path escapes repo root' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
res.json({ content });
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
} else if (err.message?.includes('not found') || err.message?.includes('No indexed')) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || 'Failed to read file' });
|
||||
}
|
||||
}
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing path' });
|
||||
return;
|
||||
});
|
||||
|
||||
// ─── GET /api/processes?repo=X ─────────────────────────────────
|
||||
// List all processes for a repo
|
||||
app.get('/api/processes', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const result = await backend.queryProcesses(repoName);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Failed to query processes' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/process?repo=X&name=Y ───────────────────────────
|
||||
// Get detailed process info including steps
|
||||
app.get('/api/process', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const name = req.query.name as string;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing "name" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backend.queryProcessDetail(name, repoName);
|
||||
if (result.error) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Failed to query process detail' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/clusters?repo=X ─────────────────────────────────
|
||||
// List all clusters for a repo
|
||||
app.get('/api/clusters', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const result = await backend.queryClusters(repoName);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Failed to query clusters' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/cluster?repo=X&name=Y ───────────────────────────
|
||||
// Get detailed cluster info including members
|
||||
app.get('/api/cluster', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const name = req.query.name as string;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing "name" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backend.queryClusterDetail(name, repoName);
|
||||
if (result.error) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.message?.includes('not found') || err.message?.includes('No indexed') ? 404 : 500)
|
||||
.json({ error: err.message || 'Failed to query cluster detail' });
|
||||
}
|
||||
const fullPath = path.join(repo.repoPath, filePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
res.json({ content });
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`GitNexus server running on http://localhost:${port}`);
|
||||
console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue