minder-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/tools/graph.py
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter, deque
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from minder.store.interfaces import IGraphRepository
|
|
8
|
+
from minder.store.interfaces import IRepositoryRepo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GraphTools:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
graph_store: IGraphRepository | None,
|
|
15
|
+
repository_store: IRepositoryRepo | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
self._graph_store = graph_store
|
|
18
|
+
self._repository_store = repository_store
|
|
19
|
+
|
|
20
|
+
async def list_repo_nodes(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
repo_id: str | None = None,
|
|
24
|
+
repo_name: str | None = None,
|
|
25
|
+
repo_path: str | None = None,
|
|
26
|
+
branch: str | None = None,
|
|
27
|
+
) -> tuple[str, list[Any]]:
|
|
28
|
+
if self._graph_store is None:
|
|
29
|
+
raise RuntimeError("Graph store is not configured")
|
|
30
|
+
|
|
31
|
+
effective_repo_name, repo_root = _resolve_repo_identity(
|
|
32
|
+
repo_name=repo_name,
|
|
33
|
+
repo_path=repo_path,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Fast path: if repo_id is known use the scoped query
|
|
37
|
+
if repo_id and hasattr(self._graph_store, "list_nodes_by_scope"):
|
|
38
|
+
repo_nodes = await self._graph_store.list_nodes_by_scope(
|
|
39
|
+
repo_id=repo_id, branch=branch
|
|
40
|
+
)
|
|
41
|
+
return effective_repo_name, repo_nodes
|
|
42
|
+
|
|
43
|
+
# Fallback: load all nodes and filter in Python (legacy / no-scope stores)
|
|
44
|
+
all_nodes = await self._graph_store.list_nodes()
|
|
45
|
+
repo_nodes = [
|
|
46
|
+
node
|
|
47
|
+
for node in all_nodes
|
|
48
|
+
if _node_belongs_to_repo(
|
|
49
|
+
node,
|
|
50
|
+
repo_id=repo_id,
|
|
51
|
+
repo_name=effective_repo_name,
|
|
52
|
+
repo_root=repo_root,
|
|
53
|
+
branch=branch,
|
|
54
|
+
)
|
|
55
|
+
]
|
|
56
|
+
return effective_repo_name, repo_nodes
|
|
57
|
+
|
|
58
|
+
async def list_repo_graph(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
repo_id: str | None = None,
|
|
62
|
+
repo_name: str | None = None,
|
|
63
|
+
repo_path: str | None = None,
|
|
64
|
+
branch: str | None = None,
|
|
65
|
+
) -> tuple[str, list[Any], list[Any]]:
|
|
66
|
+
if self._graph_store is None:
|
|
67
|
+
raise RuntimeError("Graph store is not configured")
|
|
68
|
+
|
|
69
|
+
effective_repo_name, repo_nodes = await self.list_repo_nodes(
|
|
70
|
+
repo_id=repo_id,
|
|
71
|
+
repo_name=repo_name,
|
|
72
|
+
repo_path=repo_path,
|
|
73
|
+
branch=branch,
|
|
74
|
+
)
|
|
75
|
+
repo_node_ids = {getattr(node, "id") for node in repo_nodes}
|
|
76
|
+
|
|
77
|
+
# Fast path: repo-scoped edge query
|
|
78
|
+
if repo_id and hasattr(self._graph_store, "list_edges_by_scope"):
|
|
79
|
+
all_edges = await self._graph_store.list_edges_by_scope(repo_id=repo_id)
|
|
80
|
+
else:
|
|
81
|
+
all_edges = await self._graph_store.list_edges()
|
|
82
|
+
|
|
83
|
+
repo_edges = [
|
|
84
|
+
edge
|
|
85
|
+
for edge in all_edges
|
|
86
|
+
if getattr(edge, "source_id", None) in repo_node_ids
|
|
87
|
+
and getattr(edge, "target_id", None) in repo_node_ids
|
|
88
|
+
]
|
|
89
|
+
return effective_repo_name, repo_nodes, repo_edges
|
|
90
|
+
|
|
91
|
+
async def minder_search_graph(
|
|
92
|
+
self,
|
|
93
|
+
query: str,
|
|
94
|
+
*,
|
|
95
|
+
repo_path: str | None = None,
|
|
96
|
+
repo_id: str | None = None,
|
|
97
|
+
repo_name: str | None = None,
|
|
98
|
+
branch: str | None = None,
|
|
99
|
+
node_types: list[str] | None = None,
|
|
100
|
+
languages: list[str] | None = None,
|
|
101
|
+
last_states: list[str] | None = None,
|
|
102
|
+
limit: int = 10,
|
|
103
|
+
include_linked_repos: bool = False,
|
|
104
|
+
landscape_hops: int = 1,
|
|
105
|
+
allowed_repo_scopes: list[str] | None = None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
if self._graph_store is None:
|
|
108
|
+
raise RuntimeError("Graph store is not configured")
|
|
109
|
+
|
|
110
|
+
scopes = await self._graph_scopes(
|
|
111
|
+
repo_id=repo_id,
|
|
112
|
+
repo_name=repo_name,
|
|
113
|
+
repo_path=repo_path,
|
|
114
|
+
branch=branch,
|
|
115
|
+
include_linked_repos=include_linked_repos,
|
|
116
|
+
landscape_hops=landscape_hops,
|
|
117
|
+
allowed_repo_scopes=allowed_repo_scopes,
|
|
118
|
+
)
|
|
119
|
+
effective_repo_name = (
|
|
120
|
+
scopes[0]["repo_name"] if scopes else (repo_name or "unknown")
|
|
121
|
+
)
|
|
122
|
+
allowed_types = {
|
|
123
|
+
node_type.strip() for node_type in (node_types or []) if node_type.strip()
|
|
124
|
+
}
|
|
125
|
+
allowed_languages = {
|
|
126
|
+
language.strip().lower()
|
|
127
|
+
for language in (languages or [])
|
|
128
|
+
if language.strip()
|
|
129
|
+
}
|
|
130
|
+
allowed_states = {
|
|
131
|
+
state.strip().lower() for state in (last_states or []) if state.strip()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
matches: list[dict[str, Any]] = []
|
|
135
|
+
for scope in scopes:
|
|
136
|
+
_, repo_nodes = await self.list_repo_nodes(
|
|
137
|
+
repo_id=scope["repo_id"],
|
|
138
|
+
repo_name=scope["repo_name"],
|
|
139
|
+
repo_path=scope["repo_path"],
|
|
140
|
+
branch=scope["branch"],
|
|
141
|
+
)
|
|
142
|
+
for node in repo_nodes:
|
|
143
|
+
node_type = str(getattr(node, "node_type", ""))
|
|
144
|
+
if allowed_types and node_type not in allowed_types:
|
|
145
|
+
continue
|
|
146
|
+
metadata = _metadata(node)
|
|
147
|
+
language = str(metadata.get("language", "") or "").lower()
|
|
148
|
+
last_state = str(metadata.get("last_state", "") or "").lower()
|
|
149
|
+
if allowed_languages and language not in allowed_languages:
|
|
150
|
+
continue
|
|
151
|
+
if allowed_states and last_state not in allowed_states:
|
|
152
|
+
continue
|
|
153
|
+
score = _match_score(node, query)
|
|
154
|
+
if score <= 0:
|
|
155
|
+
continue
|
|
156
|
+
item = _serialize_node(node)
|
|
157
|
+
item["score"] = score
|
|
158
|
+
item["repo_id"] = scope["repo_id"]
|
|
159
|
+
item["repo_name"] = scope["repo_name"]
|
|
160
|
+
item["branch"] = scope["branch"]
|
|
161
|
+
item["landscape_distance"] = scope["distance"]
|
|
162
|
+
item["via_link"] = scope.get("via_link")
|
|
163
|
+
matches.append(item)
|
|
164
|
+
|
|
165
|
+
matches.sort(
|
|
166
|
+
key=lambda item: (
|
|
167
|
+
int(item.get("landscape_distance", 0)),
|
|
168
|
+
-int(item["score"]),
|
|
169
|
+
item["node_type"],
|
|
170
|
+
item["name"],
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
limited = matches[: max(1, limit)]
|
|
174
|
+
return {
|
|
175
|
+
"query": query,
|
|
176
|
+
"repo_name": effective_repo_name,
|
|
177
|
+
"searched_scopes": [self._serialize_scope(scope) for scope in scopes],
|
|
178
|
+
"filters": {
|
|
179
|
+
"node_types": sorted(allowed_types),
|
|
180
|
+
"languages": sorted(allowed_languages),
|
|
181
|
+
"last_states": sorted(allowed_states),
|
|
182
|
+
},
|
|
183
|
+
"scope_count": len(scopes),
|
|
184
|
+
"count": len(limited),
|
|
185
|
+
"results": limited,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async def minder_find_impact(
|
|
189
|
+
self,
|
|
190
|
+
target: str,
|
|
191
|
+
*,
|
|
192
|
+
repo_path: str | None = None,
|
|
193
|
+
repo_id: str | None = None,
|
|
194
|
+
repo_name: str | None = None,
|
|
195
|
+
branch: str | None = None,
|
|
196
|
+
depth: int = 2,
|
|
197
|
+
limit: int = 25,
|
|
198
|
+
include_linked_repos: bool = False,
|
|
199
|
+
landscape_hops: int = 1,
|
|
200
|
+
allowed_repo_scopes: list[str] | None = None,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
if self._graph_store is None:
|
|
203
|
+
raise RuntimeError("Graph store is not configured")
|
|
204
|
+
|
|
205
|
+
scopes = await self._graph_scopes(
|
|
206
|
+
repo_id=repo_id,
|
|
207
|
+
repo_name=repo_name,
|
|
208
|
+
repo_path=repo_path,
|
|
209
|
+
branch=branch,
|
|
210
|
+
include_linked_repos=include_linked_repos,
|
|
211
|
+
landscape_hops=landscape_hops,
|
|
212
|
+
allowed_repo_scopes=allowed_repo_scopes,
|
|
213
|
+
)
|
|
214
|
+
effective_repo_name = (
|
|
215
|
+
scopes[0]["repo_name"] if scopes else (repo_name or "unknown")
|
|
216
|
+
)
|
|
217
|
+
all_matches: list[dict[str, Any]] = []
|
|
218
|
+
impacted: list[dict[str, Any]] = []
|
|
219
|
+
type_counter: Counter[str] = Counter()
|
|
220
|
+
upstream_count = 0
|
|
221
|
+
downstream_count = 0
|
|
222
|
+
synthetic_landscape_count = 0
|
|
223
|
+
|
|
224
|
+
for scope in scopes:
|
|
225
|
+
_, repo_nodes = await self.list_repo_nodes(
|
|
226
|
+
repo_id=scope["repo_id"],
|
|
227
|
+
repo_name=scope["repo_name"],
|
|
228
|
+
repo_path=scope["repo_path"],
|
|
229
|
+
branch=scope["branch"],
|
|
230
|
+
)
|
|
231
|
+
matches = _resolve_matches(repo_nodes, target)
|
|
232
|
+
if matches:
|
|
233
|
+
for node in matches[: min(5, max(1, limit))]:
|
|
234
|
+
serialized = _serialize_node(node)
|
|
235
|
+
serialized["repo_id"] = scope["repo_id"]
|
|
236
|
+
serialized["repo_name"] = scope["repo_name"]
|
|
237
|
+
serialized["branch"] = scope["branch"]
|
|
238
|
+
serialized["landscape_distance"] = scope["distance"]
|
|
239
|
+
serialized["via_link"] = scope.get("via_link")
|
|
240
|
+
all_matches.append(serialized)
|
|
241
|
+
|
|
242
|
+
repo_node_ids = {getattr(node, "id") for node in repo_nodes}
|
|
243
|
+
visited = {getattr(node, "id") for node in matches}
|
|
244
|
+
queue: deque[tuple[Any, int]] = deque(
|
|
245
|
+
(getattr(node, "id"), 0)
|
|
246
|
+
for node in matches[: min(5, max(1, limit))]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
while queue and len(impacted) < limit:
|
|
250
|
+
node_id, current_depth = queue.popleft()
|
|
251
|
+
if current_depth >= max(1, depth):
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
for direction in ("out", "in"):
|
|
255
|
+
neighbors = await self._graph_store.get_neighbors(
|
|
256
|
+
node_id, direction=direction
|
|
257
|
+
)
|
|
258
|
+
for neighbor in neighbors:
|
|
259
|
+
neighbor_id = getattr(neighbor, "id")
|
|
260
|
+
if (
|
|
261
|
+
neighbor_id not in repo_node_ids
|
|
262
|
+
or neighbor_id in visited
|
|
263
|
+
):
|
|
264
|
+
continue
|
|
265
|
+
visited.add(neighbor_id)
|
|
266
|
+
queue.append((neighbor_id, current_depth + 1))
|
|
267
|
+
serialized = _serialize_node(neighbor)
|
|
268
|
+
serialized["direction"] = (
|
|
269
|
+
"downstream" if direction == "out" else "upstream"
|
|
270
|
+
)
|
|
271
|
+
serialized["distance"] = current_depth + 1
|
|
272
|
+
serialized["repo_id"] = scope["repo_id"]
|
|
273
|
+
serialized["repo_name"] = scope["repo_name"]
|
|
274
|
+
serialized["branch"] = scope["branch"]
|
|
275
|
+
serialized["landscape_distance"] = scope["distance"]
|
|
276
|
+
serialized["via_link"] = scope.get("via_link")
|
|
277
|
+
impacted.append(serialized)
|
|
278
|
+
type_counter.update([serialized["node_type"]])
|
|
279
|
+
if direction == "out":
|
|
280
|
+
downstream_count += 1
|
|
281
|
+
else:
|
|
282
|
+
upstream_count += 1
|
|
283
|
+
if len(impacted) >= limit:
|
|
284
|
+
break
|
|
285
|
+
if len(impacted) >= limit:
|
|
286
|
+
break
|
|
287
|
+
elif scope["distance"] > 0 and len(impacted) < limit:
|
|
288
|
+
impacted.append(
|
|
289
|
+
{
|
|
290
|
+
"id": f"landscape:{scope['repo_id']}:{scope['branch']}",
|
|
291
|
+
"node_type": "repository_branch",
|
|
292
|
+
"name": f"{scope['repo_name']}:{scope['branch']}",
|
|
293
|
+
"metadata": {
|
|
294
|
+
"repo_name": scope["repo_name"],
|
|
295
|
+
"branch": scope["branch"],
|
|
296
|
+
"landscape_only": True,
|
|
297
|
+
},
|
|
298
|
+
"direction": "cross_repo",
|
|
299
|
+
"distance": 1,
|
|
300
|
+
"repo_id": scope["repo_id"],
|
|
301
|
+
"repo_name": scope["repo_name"],
|
|
302
|
+
"branch": scope["branch"],
|
|
303
|
+
"landscape_distance": scope["distance"],
|
|
304
|
+
"via_link": scope.get("via_link"),
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
type_counter.update(["repository_branch"])
|
|
308
|
+
synthetic_landscape_count += 1
|
|
309
|
+
|
|
310
|
+
all_matches.sort(
|
|
311
|
+
key=lambda item: (
|
|
312
|
+
int(item.get("landscape_distance", 0)),
|
|
313
|
+
item["node_type"],
|
|
314
|
+
item["name"],
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
seed_nodes = all_matches[: min(5, max(1, limit))]
|
|
318
|
+
|
|
319
|
+
if not seed_nodes:
|
|
320
|
+
return {
|
|
321
|
+
"target": target,
|
|
322
|
+
"repo_name": effective_repo_name,
|
|
323
|
+
"searched_scopes": [self._serialize_scope(scope) for scope in scopes],
|
|
324
|
+
"matches": [],
|
|
325
|
+
"impacted": [],
|
|
326
|
+
"summary": {
|
|
327
|
+
"match_count": 0,
|
|
328
|
+
"impacted_count": 0,
|
|
329
|
+
"upstream_count": 0,
|
|
330
|
+
"downstream_count": 0,
|
|
331
|
+
"scope_count": len(scopes),
|
|
332
|
+
"synthetic_landscape_count": 0,
|
|
333
|
+
"by_node_type": {},
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
"target": target,
|
|
339
|
+
"repo_name": effective_repo_name,
|
|
340
|
+
"searched_scopes": [self._serialize_scope(scope) for scope in scopes],
|
|
341
|
+
"matches": seed_nodes,
|
|
342
|
+
"impacted": impacted[:limit],
|
|
343
|
+
"summary": {
|
|
344
|
+
"match_count": len(seed_nodes),
|
|
345
|
+
"impacted_count": len(impacted[:limit]),
|
|
346
|
+
"upstream_count": upstream_count,
|
|
347
|
+
"downstream_count": downstream_count,
|
|
348
|
+
"scope_count": len(scopes),
|
|
349
|
+
"synthetic_landscape_count": synthetic_landscape_count,
|
|
350
|
+
"by_node_type": dict(type_counter),
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async def build_cross_repo_context(
|
|
355
|
+
self,
|
|
356
|
+
query: str,
|
|
357
|
+
*,
|
|
358
|
+
repo_path: str | None = None,
|
|
359
|
+
repo_id: str | None = None,
|
|
360
|
+
repo_name: str | None = None,
|
|
361
|
+
branch: str | None = None,
|
|
362
|
+
allowed_repo_scopes: list[str] | None = None,
|
|
363
|
+
limit: int = 5,
|
|
364
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
365
|
+
if self._graph_store is None:
|
|
366
|
+
return "", None
|
|
367
|
+
|
|
368
|
+
result = await self.minder_search_graph(
|
|
369
|
+
query,
|
|
370
|
+
repo_path=repo_path,
|
|
371
|
+
repo_id=repo_id,
|
|
372
|
+
repo_name=repo_name,
|
|
373
|
+
branch=branch,
|
|
374
|
+
limit=limit,
|
|
375
|
+
include_linked_repos=True,
|
|
376
|
+
landscape_hops=1,
|
|
377
|
+
allowed_repo_scopes=allowed_repo_scopes,
|
|
378
|
+
)
|
|
379
|
+
scopes = list(result.get("searched_scopes", []))
|
|
380
|
+
if len(scopes) <= 1:
|
|
381
|
+
return "", None
|
|
382
|
+
|
|
383
|
+
link_descriptions: list[str] = []
|
|
384
|
+
for scope in scopes[1:]:
|
|
385
|
+
via_link = scope.get("via_link") or {}
|
|
386
|
+
relation = str(via_link.get("relation", "depends_on") or "depends_on")
|
|
387
|
+
link_descriptions.append(
|
|
388
|
+
f"{scope['repo_name']}:{scope['branch']} via {relation}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
lines = [
|
|
392
|
+
"Cross-repo landscape context is available for this repository.",
|
|
393
|
+
f"Linked scopes: {', '.join(link_descriptions)}.",
|
|
394
|
+
]
|
|
395
|
+
if result.get("results"):
|
|
396
|
+
hits = [
|
|
397
|
+
f"{item['repo_name']}:{item['branch']} -> {item['name']}"
|
|
398
|
+
for item in result["results"][:limit]
|
|
399
|
+
]
|
|
400
|
+
lines.append(
|
|
401
|
+
"Related graph matches across linked scopes: " + "; ".join(hits) + "."
|
|
402
|
+
)
|
|
403
|
+
return "\n".join(lines), result
|
|
404
|
+
|
|
405
|
+
async def _graph_scopes(
|
|
406
|
+
self,
|
|
407
|
+
*,
|
|
408
|
+
repo_id: str | None,
|
|
409
|
+
repo_name: str | None,
|
|
410
|
+
repo_path: str | None,
|
|
411
|
+
branch: str | None,
|
|
412
|
+
include_linked_repos: bool,
|
|
413
|
+
landscape_hops: int,
|
|
414
|
+
allowed_repo_scopes: list[str] | None,
|
|
415
|
+
) -> list[dict[str, Any]]:
|
|
416
|
+
root_repo = await self._resolve_repository(
|
|
417
|
+
repo_id=repo_id,
|
|
418
|
+
repo_name=repo_name,
|
|
419
|
+
repo_path=repo_path,
|
|
420
|
+
)
|
|
421
|
+
effective_repo_name, _ = _resolve_repo_identity(
|
|
422
|
+
repo_name=repo_name,
|
|
423
|
+
repo_path=repo_path,
|
|
424
|
+
)
|
|
425
|
+
root_branch = (
|
|
426
|
+
str(
|
|
427
|
+
branch
|
|
428
|
+
or (
|
|
429
|
+
getattr(root_repo, "default_branch", None)
|
|
430
|
+
if root_repo is not None
|
|
431
|
+
else None
|
|
432
|
+
)
|
|
433
|
+
or ""
|
|
434
|
+
).strip()
|
|
435
|
+
or None
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
root_scope = {
|
|
439
|
+
"repo_id": str(getattr(root_repo, "id", "") or repo_id or ""),
|
|
440
|
+
"repo_name": str(
|
|
441
|
+
getattr(root_repo, "repo_name", "") or effective_repo_name
|
|
442
|
+
),
|
|
443
|
+
"repo_path": (
|
|
444
|
+
self._repository_root_path(root_repo)
|
|
445
|
+
if root_repo is not None
|
|
446
|
+
else repo_path
|
|
447
|
+
),
|
|
448
|
+
"branch": root_branch,
|
|
449
|
+
"distance": 0,
|
|
450
|
+
"via_link": None,
|
|
451
|
+
}
|
|
452
|
+
scopes = [root_scope]
|
|
453
|
+
if not include_linked_repos or root_repo is None:
|
|
454
|
+
return scopes
|
|
455
|
+
linked = await self._expand_linked_scopes(
|
|
456
|
+
root_repo=root_repo,
|
|
457
|
+
branch=root_branch,
|
|
458
|
+
max_hops=max(1, landscape_hops),
|
|
459
|
+
allowed_repo_scopes=allowed_repo_scopes,
|
|
460
|
+
)
|
|
461
|
+
scopes.extend(linked)
|
|
462
|
+
return scopes
|
|
463
|
+
|
|
464
|
+
async def _expand_linked_scopes(
|
|
465
|
+
self,
|
|
466
|
+
*,
|
|
467
|
+
root_repo: Any,
|
|
468
|
+
branch: str | None,
|
|
469
|
+
max_hops: int,
|
|
470
|
+
allowed_repo_scopes: list[str] | None,
|
|
471
|
+
) -> list[dict[str, Any]]:
|
|
472
|
+
repositories = await self._list_repositories()
|
|
473
|
+
if not repositories:
|
|
474
|
+
return []
|
|
475
|
+
root_branch = str(
|
|
476
|
+
branch or getattr(root_repo, "default_branch", None) or ""
|
|
477
|
+
).strip()
|
|
478
|
+
start_key = (str(getattr(root_repo, "id")), root_branch)
|
|
479
|
+
queue: deque[tuple[Any, str, int]] = deque([(root_repo, root_branch, 0)])
|
|
480
|
+
visited = {start_key}
|
|
481
|
+
scopes: list[dict[str, Any]] = []
|
|
482
|
+
|
|
483
|
+
while queue:
|
|
484
|
+
current_repo, current_branch, current_distance = queue.popleft()
|
|
485
|
+
if current_distance >= max_hops:
|
|
486
|
+
continue
|
|
487
|
+
current_repo_id = str(getattr(current_repo, "id"))
|
|
488
|
+
current_repo_name = str(getattr(current_repo, "repo_name", "") or "")
|
|
489
|
+
|
|
490
|
+
for owner in repositories:
|
|
491
|
+
owner_id = str(getattr(owner, "id"))
|
|
492
|
+
for link in self._repository_branch_links(owner):
|
|
493
|
+
source_repo_id = str(link.get("source_repo_id", "") or owner_id)
|
|
494
|
+
source_branch = str(link.get("source_branch", "") or "").strip()
|
|
495
|
+
target_repo_id = str(link.get("target_repo_id", "") or "").strip()
|
|
496
|
+
target_repo_name = str(
|
|
497
|
+
link.get("target_repo_name", "") or ""
|
|
498
|
+
).strip()
|
|
499
|
+
target_branch = str(link.get("target_branch", "") or "").strip()
|
|
500
|
+
if not source_branch or not target_branch:
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
next_repo = None
|
|
504
|
+
next_branch = ""
|
|
505
|
+
via_link: dict[str, Any] | None = None
|
|
506
|
+
if (
|
|
507
|
+
source_repo_id == current_repo_id
|
|
508
|
+
and source_branch == current_branch
|
|
509
|
+
):
|
|
510
|
+
next_repo = self._resolve_repository_reference(
|
|
511
|
+
repositories=repositories,
|
|
512
|
+
target_repo_id=target_repo_id,
|
|
513
|
+
target_repo_name=target_repo_name,
|
|
514
|
+
target_repo_url=link.get("target_repo_url"),
|
|
515
|
+
)
|
|
516
|
+
next_branch = target_branch
|
|
517
|
+
via_link = {
|
|
518
|
+
"relation": str(
|
|
519
|
+
link.get("relation", "depends_on") or "depends_on"
|
|
520
|
+
),
|
|
521
|
+
"direction": "outbound",
|
|
522
|
+
"source_repo_name": current_repo_name,
|
|
523
|
+
"source_branch": current_branch,
|
|
524
|
+
}
|
|
525
|
+
elif (
|
|
526
|
+
target_repo_id == current_repo_id
|
|
527
|
+
and target_branch == current_branch
|
|
528
|
+
):
|
|
529
|
+
next_repo = owner
|
|
530
|
+
next_branch = source_branch
|
|
531
|
+
via_link = {
|
|
532
|
+
"relation": str(
|
|
533
|
+
link.get("relation", "depends_on") or "depends_on"
|
|
534
|
+
),
|
|
535
|
+
"direction": "inbound",
|
|
536
|
+
"source_repo_name": str(
|
|
537
|
+
getattr(owner, "repo_name", "") or ""
|
|
538
|
+
),
|
|
539
|
+
"source_branch": source_branch,
|
|
540
|
+
}
|
|
541
|
+
if next_repo is None or not next_branch:
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
next_key = (str(getattr(next_repo, "id")), next_branch)
|
|
545
|
+
if next_key in visited:
|
|
546
|
+
continue
|
|
547
|
+
if not _repo_matches_scopes(
|
|
548
|
+
next_repo,
|
|
549
|
+
self._repository_root_path(next_repo),
|
|
550
|
+
allowed_repo_scopes,
|
|
551
|
+
):
|
|
552
|
+
continue
|
|
553
|
+
visited.add(next_key)
|
|
554
|
+
queue.append((next_repo, next_branch, current_distance + 1))
|
|
555
|
+
scopes.append(
|
|
556
|
+
{
|
|
557
|
+
"repo_id": str(getattr(next_repo, "id")),
|
|
558
|
+
"repo_name": str(getattr(next_repo, "repo_name", "") or ""),
|
|
559
|
+
"repo_path": self._repository_root_path(next_repo),
|
|
560
|
+
"branch": next_branch,
|
|
561
|
+
"distance": current_distance + 1,
|
|
562
|
+
"via_link": via_link,
|
|
563
|
+
}
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
return scopes
|
|
567
|
+
|
|
568
|
+
async def _resolve_repository(
|
|
569
|
+
self,
|
|
570
|
+
*,
|
|
571
|
+
repo_id: str | None,
|
|
572
|
+
repo_name: str | None,
|
|
573
|
+
repo_path: str | None,
|
|
574
|
+
) -> Any | None:
|
|
575
|
+
repositories = await self._list_repositories()
|
|
576
|
+
if not repositories:
|
|
577
|
+
return None
|
|
578
|
+
normalized_repo_id = str(repo_id or "").strip()
|
|
579
|
+
normalized_repo_name = str(repo_name or "").strip()
|
|
580
|
+
normalized_repo_path = str(Path(repo_path).resolve()) if repo_path else ""
|
|
581
|
+
for repository in repositories:
|
|
582
|
+
if (
|
|
583
|
+
normalized_repo_id
|
|
584
|
+
and str(getattr(repository, "id")) == normalized_repo_id
|
|
585
|
+
):
|
|
586
|
+
return repository
|
|
587
|
+
if (
|
|
588
|
+
normalized_repo_name
|
|
589
|
+
and str(getattr(repository, "repo_name", "") or "")
|
|
590
|
+
== normalized_repo_name
|
|
591
|
+
):
|
|
592
|
+
return repository
|
|
593
|
+
repository_root = self._repository_root_path(repository)
|
|
594
|
+
if (
|
|
595
|
+
normalized_repo_path
|
|
596
|
+
and repository_root
|
|
597
|
+
and str(Path(repository_root).resolve()) == normalized_repo_path
|
|
598
|
+
):
|
|
599
|
+
return repository
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
async def _list_repositories(self) -> list[Any]:
|
|
603
|
+
if self._repository_store is None:
|
|
604
|
+
return []
|
|
605
|
+
return await self._repository_store.list_repositories()
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _repository_root_path(repository: Any) -> str | None:
|
|
609
|
+
if repository is None:
|
|
610
|
+
return None
|
|
611
|
+
state_path = str(getattr(repository, "state_path", "") or "")
|
|
612
|
+
if not state_path:
|
|
613
|
+
return None
|
|
614
|
+
state_root = Path(state_path)
|
|
615
|
+
if state_root.name == ".minder":
|
|
616
|
+
return str(state_root.parent)
|
|
617
|
+
return str(state_root)
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def _repository_branch_links(repository: Any) -> list[dict[str, Any]]:
|
|
621
|
+
relationships = dict(getattr(repository, "relationships", {}) or {})
|
|
622
|
+
raw_links = relationships.get("cross_repo_branches", [])
|
|
623
|
+
if not isinstance(raw_links, list):
|
|
624
|
+
return []
|
|
625
|
+
return [dict(link) for link in raw_links if isinstance(link, dict)]
|
|
626
|
+
|
|
627
|
+
@staticmethod
|
|
628
|
+
def _resolve_repository_reference(
|
|
629
|
+
*,
|
|
630
|
+
repositories: list[Any],
|
|
631
|
+
target_repo_id: Any,
|
|
632
|
+
target_repo_name: Any,
|
|
633
|
+
target_repo_url: Any,
|
|
634
|
+
) -> Any | None:
|
|
635
|
+
normalized_target_id = str(target_repo_id or "").strip()
|
|
636
|
+
normalized_target_name = str(target_repo_name or "").strip()
|
|
637
|
+
normalized_target_url = _normalize_repository_remote(target_repo_url)
|
|
638
|
+
for repository in repositories:
|
|
639
|
+
if (
|
|
640
|
+
normalized_target_id
|
|
641
|
+
and str(getattr(repository, "id")) == normalized_target_id
|
|
642
|
+
):
|
|
643
|
+
return repository
|
|
644
|
+
if (
|
|
645
|
+
normalized_target_url
|
|
646
|
+
and _normalize_repository_remote(getattr(repository, "repo_url", None))
|
|
647
|
+
== normalized_target_url
|
|
648
|
+
):
|
|
649
|
+
return repository
|
|
650
|
+
if (
|
|
651
|
+
normalized_target_name
|
|
652
|
+
and str(getattr(repository, "repo_name", "") or "")
|
|
653
|
+
== normalized_target_name
|
|
654
|
+
):
|
|
655
|
+
return repository
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
@staticmethod
|
|
659
|
+
def _serialize_scope(scope: dict[str, Any]) -> dict[str, Any]:
|
|
660
|
+
return {
|
|
661
|
+
"repo_id": scope["repo_id"],
|
|
662
|
+
"repo_name": scope["repo_name"],
|
|
663
|
+
"repo_path": scope["repo_path"],
|
|
664
|
+
"branch": scope["branch"],
|
|
665
|
+
"distance": scope["distance"],
|
|
666
|
+
"via_link": scope.get("via_link"),
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _metadata(node: Any) -> dict[str, Any]:
|
|
671
|
+
value = getattr(node, "node_metadata", {}) or {}
|
|
672
|
+
return value if isinstance(value, dict) else {}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _resolve_repo_identity(
|
|
676
|
+
*,
|
|
677
|
+
repo_name: str | None,
|
|
678
|
+
repo_path: str | None,
|
|
679
|
+
) -> tuple[str, Path | None]:
|
|
680
|
+
if repo_path:
|
|
681
|
+
repo_root = Path(repo_path).resolve()
|
|
682
|
+
return repo_name or repo_root.name, repo_root
|
|
683
|
+
return repo_name or "unknown", None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _node_belongs_to_repo(
|
|
687
|
+
node: Any,
|
|
688
|
+
*,
|
|
689
|
+
repo_id: str | None,
|
|
690
|
+
repo_name: str,
|
|
691
|
+
repo_root: Path | None,
|
|
692
|
+
branch: str | None = None,
|
|
693
|
+
) -> bool:
|
|
694
|
+
metadata = _metadata(node)
|
|
695
|
+
|
|
696
|
+
# v2: check the actual repo_id column first
|
|
697
|
+
node_repo_id = str(getattr(node, "repo_id", "") or "")
|
|
698
|
+
if repo_id and node_repo_id:
|
|
699
|
+
if node_repo_id != repo_id:
|
|
700
|
+
return False
|
|
701
|
+
# Branch filter
|
|
702
|
+
if branch is not None:
|
|
703
|
+
node_branch = str(getattr(node, "branch", "") or "")
|
|
704
|
+
if node_branch and node_branch != branch:
|
|
705
|
+
return False
|
|
706
|
+
return True
|
|
707
|
+
|
|
708
|
+
# Legacy fallback: repo_id stored inside metadata JSON
|
|
709
|
+
meta_repo_id = str(metadata.get("repo_id", "") or "")
|
|
710
|
+
if repo_id and meta_repo_id:
|
|
711
|
+
if meta_repo_id != repo_id:
|
|
712
|
+
return False
|
|
713
|
+
if branch is not None:
|
|
714
|
+
meta_branch = str(metadata.get("branch", "") or "")
|
|
715
|
+
if meta_branch and meta_branch != branch:
|
|
716
|
+
return False
|
|
717
|
+
return True
|
|
718
|
+
|
|
719
|
+
# Further legacy: match by project/repo name
|
|
720
|
+
project = str(metadata.get("project", "") or "")
|
|
721
|
+
if project == repo_name:
|
|
722
|
+
return True
|
|
723
|
+
|
|
724
|
+
repository_name = str(metadata.get("repository_name", "") or "")
|
|
725
|
+
if repository_name == repo_name:
|
|
726
|
+
return True
|
|
727
|
+
|
|
728
|
+
path_value = str(metadata.get("path", "") or "")
|
|
729
|
+
if path_value and repo_root is not None:
|
|
730
|
+
try:
|
|
731
|
+
path = Path(path_value)
|
|
732
|
+
if path.is_absolute() and str(path).startswith(str(repo_root)):
|
|
733
|
+
return True
|
|
734
|
+
except (TypeError, ValueError):
|
|
735
|
+
return False
|
|
736
|
+
|
|
737
|
+
return False
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _resolve_matches(nodes: list[Any], target: str) -> list[Any]:
|
|
741
|
+
scored: list[tuple[int, Any]] = []
|
|
742
|
+
for node in nodes:
|
|
743
|
+
score = _match_score(node, target)
|
|
744
|
+
if score > 0:
|
|
745
|
+
scored.append((score, node))
|
|
746
|
+
scored.sort(key=lambda item: (-item[0], str(getattr(item[1], "name", ""))))
|
|
747
|
+
if not scored:
|
|
748
|
+
return []
|
|
749
|
+
best_score = scored[0][0]
|
|
750
|
+
return [node for score, node in scored if score == best_score]
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _match_score(node: Any, query: str) -> int:
|
|
754
|
+
normalized_query = query.strip().lower()
|
|
755
|
+
if not normalized_query:
|
|
756
|
+
return 0
|
|
757
|
+
metadata = _metadata(node)
|
|
758
|
+
node_name = str(getattr(node, "name", "") or "")
|
|
759
|
+
candidates = {
|
|
760
|
+
node_name,
|
|
761
|
+
str(metadata.get("symbol", "") or ""),
|
|
762
|
+
str(metadata.get("path", "") or ""),
|
|
763
|
+
str(metadata.get("route_path", "") or ""),
|
|
764
|
+
str(metadata.get("text", "") or ""),
|
|
765
|
+
str(metadata.get("method", "") or ""),
|
|
766
|
+
str(metadata.get("language", "") or ""),
|
|
767
|
+
str(metadata.get("last_state", "") or ""),
|
|
768
|
+
str(metadata.get("last_commit_summary", "") or ""),
|
|
769
|
+
str(metadata.get("history_summary", "") or ""),
|
|
770
|
+
}
|
|
771
|
+
lowered = [candidate.lower() for candidate in candidates if candidate]
|
|
772
|
+
if any(candidate == normalized_query for candidate in lowered):
|
|
773
|
+
return 100
|
|
774
|
+
if any(
|
|
775
|
+
Path(candidate).name.lower() == normalized_query
|
|
776
|
+
for candidate in candidates
|
|
777
|
+
if candidate
|
|
778
|
+
):
|
|
779
|
+
return 90
|
|
780
|
+
if any(candidate.endswith(f"::{query}") for candidate in candidates if candidate):
|
|
781
|
+
return 80
|
|
782
|
+
if any(normalized_query in candidate for candidate in lowered):
|
|
783
|
+
return 60
|
|
784
|
+
return 0
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _serialize_node(node: Any) -> dict[str, Any]:
|
|
788
|
+
metadata = _metadata(node)
|
|
789
|
+
return {
|
|
790
|
+
"id": str(getattr(node, "id")),
|
|
791
|
+
"node_type": str(getattr(node, "node_type", "")),
|
|
792
|
+
"name": str(getattr(node, "name", "")),
|
|
793
|
+
"metadata": metadata,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _normalize_repository_remote(repo_url: str | None) -> str | None:
|
|
798
|
+
if repo_url is None:
|
|
799
|
+
return None
|
|
800
|
+
raw_url = str(repo_url).strip()
|
|
801
|
+
if not raw_url:
|
|
802
|
+
return None
|
|
803
|
+
if raw_url.startswith("git@"):
|
|
804
|
+
host_and_path = raw_url[4:]
|
|
805
|
+
if ":" in host_and_path:
|
|
806
|
+
host, path = host_and_path.split(":", 1)
|
|
807
|
+
normalized_path = path[:-4] if path.endswith(".git") else path
|
|
808
|
+
return f"ssh://{host}/{normalized_path}"
|
|
809
|
+
if raw_url.endswith(".git"):
|
|
810
|
+
raw_url = raw_url[:-4]
|
|
811
|
+
return raw_url.rstrip("/")
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _repo_matches_scopes(
|
|
815
|
+
repository: Any,
|
|
816
|
+
repo_path: str | None,
|
|
817
|
+
allowed_repo_scopes: list[str] | None,
|
|
818
|
+
) -> bool:
|
|
819
|
+
scopes = [
|
|
820
|
+
scope.strip().rstrip("/")
|
|
821
|
+
for scope in (allowed_repo_scopes or [])
|
|
822
|
+
if scope and scope.strip()
|
|
823
|
+
]
|
|
824
|
+
if not scopes or "*" in scopes:
|
|
825
|
+
return True
|
|
826
|
+
repo_name = str(getattr(repository, "repo_name", "") or "").strip()
|
|
827
|
+
repo_url = _normalize_repository_remote(getattr(repository, "repo_url", None))
|
|
828
|
+
candidates = {repo_name}
|
|
829
|
+
if repo_path:
|
|
830
|
+
repo_root = str(Path(repo_path).resolve()).rstrip("/")
|
|
831
|
+
candidates.add(repo_root)
|
|
832
|
+
candidates.add(Path(repo_root).name)
|
|
833
|
+
if repo_url:
|
|
834
|
+
candidates.add(repo_url)
|
|
835
|
+
for scope in scopes:
|
|
836
|
+
for candidate in candidates:
|
|
837
|
+
if candidate == scope or candidate.startswith(f"{scope}/"):
|
|
838
|
+
return True
|
|
839
|
+
return False
|