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.
Files changed (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. 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