Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,21 @@ GEMINI_API_KEY=<YOUR_GEMINI_API_KEY>
# Optional Uvicorn bind settings used by start.sh / make run-*
HOST=0.0.0.0
PORT=5000

# ---------------------------------------------------------------------------
# Continuous graph updates (webhook / poll-watcher)
# ---------------------------------------------------------------------------

# Shared secret used for GitHub HMAC verification or GitLab's
# X-Gitlab-Token verification. Leave empty to require
# Authorization: Bearer <SECRET_TOKEN> on /api/webhook instead.
WEBHOOK_SECRET=

# Name of the branch to track for automatic incremental updates.
# Only push events targeting this branch trigger a graph update.
TRACKED_BRANCH=main

# Seconds between automatic poll-watcher checks (0 = disable poll-watcher).
# The poll-watcher runs as a background task and checks every tracked
# repository for new commits on TRACKED_BRANCH.
POLL_INTERVAL=60
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ cp .env.template .env
| `MODEL_NAME` | LiteLLM model used by `/api/chat` | No | `gemini/gemini-flash-lite-latest` |
| `HOST` | Optional Uvicorn bind host for `start.sh`/`make run-*` | No | `0.0.0.0` or `127.0.0.1` depending on command |
| `PORT` | Optional Uvicorn bind port for `start.sh`/`make run-*` | No | `5000` |
| `WEBHOOK_SECRET` | Shared secret for GitHub HMAC or GitLab `X-Gitlab-Token` verification on `/api/webhook` | No | empty |
| `TRACKED_BRANCH` | Branch watched by the webhook and poll-watcher | No | `main` |
| `POLL_INTERVAL` | Seconds between background poll checks (`0` disables polling) | No | `60` |

The chat endpoint also needs the provider credential expected by your chosen `MODEL_NAME`. The default model is Gemini, so set `GEMINI_API_KEY` unless you switch to a different LiteLLM provider/model.

Expand All @@ -97,6 +100,8 @@ The chat endpoint also needs the provider credential expected by your chosen `MO
- If `SECRET_TOKEN` is unset, the current implementation accepts requests without an `Authorization` header.
- Setting `CODE_GRAPH_PUBLIC=1` makes the read-only endpoints public even when `SECRET_TOKEN` is configured.

Continuous graph updates can be triggered either by posting a GitHub/GitLab push payload to `/api/webhook` or by enabling the background poll-watcher with `POLL_INTERVAL > 0`. When `WEBHOOK_SECRET` is unset, `/api/webhook` falls back to the same bearer-token auth used by the other mutating endpoints.

### 3. Install dependencies

```bash
Expand Down Expand Up @@ -241,6 +246,7 @@ A C analyzer exists in the source tree, but it is commented out and is not curre
| POST | `/api/analyze_folder` | Analyze a local source folder |
| POST | `/api/analyze_repo` | Clone and analyze a git repository |
| POST | `/api/switch_commit` | Switch the indexed repository to a specific commit |
| POST | `/api/webhook` | Receive a GitHub/GitLab push event and apply an incremental graph update |

## License

Expand Down
75 changes: 72 additions & 3 deletions api/analyzers/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

Expand All @@ -7,6 +8,14 @@
from abc import ABC, abstractmethod
from multilspy import SyncLanguageServer

from ..graph import Graph


@dataclass(frozen=True)
class ResolvedEntityRef:
id: int


class AbstractAnalyzer(ABC):
def __init__(self, language: Language) -> None:
self.language = language
Expand Down Expand Up @@ -56,8 +65,69 @@ def resolve(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: P
try:
locations = lsp.request_definition(str(file_path), node.start_point.row, node.start_point.column)
return [(files[Path(self.resolve_path(location['absolutePath'], path))], files[Path(self.resolve_path(location['absolutePath'], path))].tree.root_node.descendant_for_point_range(Point(location['range']['start']['line'], location['range']['start']['character']), Point(location['range']['end']['line'], location['range']['end']['character']))) for location in locations if location and Path(self.resolve_path(location['absolutePath'], path)) in files]
except Exception as e:
except Exception:
return []

def resolve_entities(
self,
files: dict[Path, File],
lsp: SyncLanguageServer,
file_path: Path,
path: Path,
node: Node,
graph: Graph,
parent_types: list[str],
graph_labels: list[str],
reject_parent_types: Optional[set[str]] = None,
) -> list[Entity | ResolvedEntityRef]:
try:
locations = lsp.request_definition(
str(file_path), node.start_point.row, node.start_point.column
)
except Exception:
return []

resolved_entities: list[Entity | ResolvedEntityRef] = []
for location in locations:
if not location or 'absolutePath' not in location:
continue

resolved_path = Path(self.resolve_path(location['absolutePath'], path))
if resolved_path in files:
file = files[resolved_path]
resolved_node = file.tree.root_node.descendant_for_point_range(
Point(
location['range']['start']['line'],
location['range']['start']['character'],
),
Point(
location['range']['end']['line'],
location['range']['end']['character'],
),
)
entity_node = self.find_parent(resolved_node, parent_types)
if entity_node is None:
continue
if reject_parent_types and entity_node.type in reject_parent_types:
continue

entity = file.entities.get(entity_node)
if entity is not None:
resolved_entities.append(entity)
continue

if graph is None:
continue

graph_entity = graph.get_entity_at_position(
str(resolved_path),
location['range']['start']['line'],
graph_labels,
)
if graph_entity is not None:
resolved_entities.append(ResolvedEntityRef(graph_entity.id))

return resolved_entities

@abstractmethod
def add_dependencies(self, path: Path, files: list[Path]):
Expand Down Expand Up @@ -133,7 +203,7 @@ def add_symbols(self, entity: Entity) -> None:
pass

@abstractmethod
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph: Graph, key: str, symbol: Node) -> list[Entity | ResolvedEntityRef]:
"""
Resolve a symbol to an entity.

Expand All @@ -148,4 +218,3 @@ def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
"""

pass

45 changes: 26 additions & 19 deletions api/analyzers/csharp/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,34 +105,41 @@ def is_dependency(self, file_path: str) -> bool:
def resolve_path(self, file_path: str, path: Path) -> str:
return file_path

def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
return res
def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, node: Node) -> list[Entity]:
return self.resolve_entities(
files,
lsp,
file_path,
path,
node,
graph,
['class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration'],
['Class', 'Interface', 'Enum', 'Struct'],
)

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, node: Node) -> list[Entity]:
if node.type == 'invocation_expression':
func_node = node.child_by_field_name('function')
if func_node and func_node.type == 'member_access_expression':
func_node = func_node.child_by_field_name('name')
if func_node:
node = func_node
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
method_dec = self.find_parent(resolved_node, ['method_declaration', 'constructor_declaration', 'class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration'])
if method_dec and method_dec.type in ['class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration']:
continue
if method_dec in file.entities:
res.append(file.entities[method_dec])
return res
return self.resolve_entities(
files,
lsp,
file_path,
path,
node,
graph,
['method_declaration', 'constructor_declaration', 'class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration'],
['Method', 'Constructor'],
{'class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration'},
)

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, key: str, symbol: Node) -> list[Entity]:
if key in ["implement_interface", "base_class", "extend_interface", "parameters", "return_type"]:
return self.resolve_type(files, lsp, file_path, path, symbol)
return self.resolve_type(files, lsp, file_path, path, graph, symbol)
elif key in ["call"]:
return self.resolve_method(files, lsp, file_path, path, symbol)
return self.resolve_method(files, lsp, file_path, path, graph, symbol)
else:
raise ValueError(f"Unknown key {key}")
48 changes: 28 additions & 20 deletions api/analyzers/java/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import os
from pathlib import Path
import subprocess
from ...entities import *
from ...entities.entity import Entity
from ...entities.file import File
from typing import Optional
from ..analyzer import AbstractAnalyzer

Expand Down Expand Up @@ -102,28 +103,35 @@ def resolve_path(self, file_path: str, path: Path) -> str:
return f"{path}/temp_deps/{args[1]}/{targs}/{args[-1]}"
return file_path

def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_declaration', 'interface_declaration', 'enum_declaration'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
return res
def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, node: Node) -> list[Entity]:
return self.resolve_entities(
files,
lsp,
file_path,
path,
node,
graph,
['class_declaration', 'interface_declaration', 'enum_declaration'],
['Class', 'Interface', 'Enum'],
)

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
for file, resolved_node in self.resolve(files, lsp, file_path, path, node.child_by_field_name('name')):
method_dec = self.find_parent(resolved_node, ['method_declaration', 'constructor_declaration', 'class_declaration', 'interface_declaration', 'enum_declaration'])
if method_dec and method_dec.type in ['class_declaration', 'interface_declaration', 'enum_declaration']:
continue
if method_dec in file.entities:
res.append(file.entities[method_dec])
return res
def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, node: Node) -> list[Entity]:
return self.resolve_entities(
files,
lsp,
file_path,
path,
node.child_by_field_name('name'),
graph,
['method_declaration', 'constructor_declaration', 'class_declaration', 'interface_declaration', 'enum_declaration'],
['Method', 'Constructor'],
{'class_declaration', 'interface_declaration', 'enum_declaration'},
)

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, key: str, symbol: Node) -> list[Entity]:
if key in ["implement_interface", "base_class", "extend_interface", "parameters", "return_type"]:
return self.resolve_type(files, lsp, file_path, path, symbol)
return self.resolve_type(files, lsp, file_path, path, graph, symbol)
elif key in ["call"]:
return self.resolve_method(files, lsp, file_path, path, symbol)
return self.resolve_method(files, lsp, file_path, path, graph, symbol)
else:
raise ValueError(f"Unknown key {key}")
47 changes: 27 additions & 20 deletions api/analyzers/python/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from pathlib import Path

import tomllib
from ...entities import *
from ...entities.entity import Entity
from ...entities.file import File
from typing import Optional
from ..analyzer import AbstractAnalyzer

Expand Down Expand Up @@ -91,34 +92,40 @@ def is_dependency(self, file_path: str) -> bool:
def resolve_path(self, file_path: str, path: Path) -> str:
return file_path

def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path, node: Node) -> list[Entity]:
res = []
def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path, graph, node: Node) -> list[Entity]:
if node.type == 'attribute':
node = node.child_by_field_name('attribute')
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_definition'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
return res
return self.resolve_entities(
files,
lsp,
file_path,
path,
node,
graph,
['class_definition'],
['Class'],
)

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, node: Node) -> list[Entity]:
if node.type == 'call':
node = node.child_by_field_name('function')
if node.type == 'attribute':
node = node.child_by_field_name('attribute')
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
method_dec = self.find_parent(resolved_node, ['function_definition', 'class_definition'])
if not method_dec:
continue
if method_dec in file.entities:
res.append(file.entities[method_dec])
return res
return self.resolve_entities(
files,
lsp,
file_path,
path,
node,
graph,
['function_definition', 'class_definition'],
['Function', 'Class'],
)

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph, key: str, symbol: Node) -> list[Entity]:
if key in ["base_class", "parameters", "return_type"]:
return self.resolve_type(files, lsp, file_path, path, symbol)
return self.resolve_type(files, lsp, file_path, path, graph, symbol)
elif key in ["call"]:
return self.resolve_method(files, lsp, file_path, path, symbol)
return self.resolve_method(files, lsp, file_path, path, graph, symbol)
else:
raise ValueError(f"Unknown key {key}")
3 changes: 1 addition & 2 deletions api/analyzers/source_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
file = self.files[file_path]
logging.info(f'Processing file ({i + 1}/{files_len}): {file_path}')
for _, entity in file.entities.items():
entity.resolved_symbol(lambda key, symbol, fp=file_path: analyzers[fp.suffix].resolve_symbol(self.files, lsps[fp.suffix], fp, path, key, symbol))
entity.resolved_symbol(lambda key, symbol, fp=file_path: analyzers[fp.suffix].resolve_symbol(self.files, lsps[fp.suffix], fp, path, graph, key, symbol))
for key, symbols in entity.symbols.items():
for symbol in symbols:
if len(symbol.resolved_symbol) == 0:
Expand Down Expand Up @@ -220,4 +220,3 @@ def analyze_local_repository(self, path: str, ignore: Optional[list[str]] = None
graph.set_graph_commit(current_commit.short_id)

return graph

29 changes: 28 additions & 1 deletion api/git_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,28 @@
from .git_utils import *
from . import git_utils as git_utils
from .git_utils import (
GitRepoName as GitRepoName,
build_commit_graph as build_commit_graph,
classify_changes as classify_changes,
is_ignored as is_ignored,
switch_commit as switch_commit,
)
from .git_graph import GitGraph as GitGraph
from .incremental_update import (
fetch_remote as fetch_remote,
get_remote_head as get_remote_head,
incremental_update as incremental_update,
repo_local_path as repo_local_path,
)

__all__ = [
"GitRepoName",
"GitGraph",
"build_commit_graph",
"classify_changes",
"fetch_remote",
"get_remote_head",
"incremental_update",
"is_ignored",
"repo_local_path",
"switch_commit",
]
Loading
Loading