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
29 changes: 29 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,31 @@ 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.

#### Setting up a webhook

After indexing a repository with `/api/analyze_repo`, you can register a webhook so the graph stays in sync automatically.

**GitHub:**

1. Go to your repository → **Settings** → **Webhooks** → **Add webhook**.
2. Set **Payload URL** to `https://<your-server>/api/webhook`.
3. Set **Content type** to `application/json`.
4. Set **Secret** to the same value as your `WEBHOOK_SECRET` environment variable.
5. Under **Which events?**, select **Just the push event**.
6. Click **Add webhook**.

**GitLab:**

1. Go to your project → **Settings** → **Webhooks** → **Add new webhook**.
2. Set **URL** to `https://<your-server>/api/webhook`.
3. Set **Secret token** to the same value as your `WEBHOOK_SECRET` environment variable.
4. Check **Push events** as the trigger.
5. Click **Add webhook**.

> **Tip:** If you cannot configure a webhook (e.g. you don't have admin access), enable the background poll-watcher instead by setting `POLL_INTERVAL` to a non-zero value (in seconds). It will periodically check the remote for new commits on `TRACKED_BRANCH`.

### 3. Install dependencies

```bash
Expand Down Expand Up @@ -241,6 +269,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

48 changes: 28 additions & 20 deletions api/analyzers/csharp/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from ...entities.entity import Entity
from ...entities.file import File
from typing import Optional
from ..analyzer import AbstractAnalyzer
from ..analyzer import AbstractAnalyzer, ResolvedEntityRef
from ...graph import Graph

import tree_sitter_c_sharp as tscsharp
from tree_sitter import Language, Node
Expand Down Expand Up @@ -105,34 +106,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: Graph, node: Node) -> list[Entity | ResolvedEntityRef]:
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: Graph, node: Node) -> list[Entity | ResolvedEntityRef]:
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: Graph, key: str, symbol: Node) -> list[Entity | ResolvedEntityRef]:
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}")
53 changes: 31 additions & 22 deletions api/analyzers/java/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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
from ..analyzer import AbstractAnalyzer, ResolvedEntityRef
from ...graph import Graph

from multilspy import SyncLanguageServer

Expand Down Expand Up @@ -102,28 +104,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: Graph, node: Node) -> list[Entity | ResolvedEntityRef]:
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_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, graph: Graph, node: Node) -> list[Entity | ResolvedEntityRef]:
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, graph: Graph, key: str, symbol: Node) -> list[Entity | ResolvedEntityRef]:
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}")
52 changes: 30 additions & 22 deletions api/analyzers/python/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
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
from ..analyzer import AbstractAnalyzer, ResolvedEntityRef
from ...graph import Graph

import tree_sitter_python as tspython
from tree_sitter import Language, Node
Expand Down Expand Up @@ -91,34 +93,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: Path, graph: Graph, node: Node) -> list[Entity | ResolvedEntityRef]:
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: Graph, node: Node) -> list[Entity | ResolvedEntityRef]:
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

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
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, graph: Graph, key: str, symbol: Node) -> list[Entity | ResolvedEntityRef]:
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}")
Loading
Loading