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
@@ -0,0 +1,1895 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from collections import Counter
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ from minder.application.admin.dto import (
11
+ ActivityEventPayload,
12
+ AdminLoginPayload,
13
+ AdminSessionPayload,
14
+ AuditEventPayload,
15
+ AuditListPayload,
16
+ ClientConnectionTestPayload,
17
+ ClientDetailPayload,
18
+ ClientKeyPayload,
19
+ ClientListPayload,
20
+ ClientRepositoryResolvePayload,
21
+ ClientPayload,
22
+ CreateClientPayload,
23
+ CreateUserPayload,
24
+ GraphSyncRequest,
25
+ GraphSyncResultPayload,
26
+ OnboardingPayload,
27
+ DeleteRepositoryPayload,
28
+ RepositoryBranchLinkListPayload,
29
+ RepositoryBranchLinkPayload,
30
+ RepositoryBranchListPayload,
31
+ RepositoryBranchPayload,
32
+ RepositoryDetailPayload,
33
+ RepositoryGraphEdgePayload,
34
+ RepositoryGraphImpactPayload,
35
+ RepositoryGraphMapPayload,
36
+ RepositoryGraphNodePayload,
37
+ RepositoryGraphSearchPayload,
38
+ RepositoryGraphSummaryPayload,
39
+ RepositoryLandscapePayload,
40
+ RepositoryListPayload,
41
+ RepositoryPayload,
42
+ RevokeKeysPayload,
43
+ SetupResultPayload,
44
+ UserDetailPayload,
45
+ UserListPayload,
46
+ UserPayload,
47
+ WorkflowDetailPayload,
48
+ WorkflowListPayload,
49
+ WorkflowPayload,
50
+ WorkflowStepPayload,
51
+ )
52
+ from minder.auth.service import AuthService
53
+ from minder.config import MinderConfig
54
+ from minder.store.interfaces import IGraphRepository, IOperationalStore
55
+ from minder.tools.graph import GraphTools
56
+ from minder.tools.registry import SCOPEABLE_TOOLS
57
+
58
+ DASHBOARD_TOOL_SCOPE_OPTIONS = [tool.name for tool in SCOPEABLE_TOOLS]
59
+
60
+ DASHBOARD_TOOL_SCOPE_PRESETS: dict[str, list[str]] = {
61
+ "Query Only": ["minder_query", "minder_search_code", "minder_search_errors"],
62
+ "Read Only": [
63
+ "minder_query",
64
+ "minder_search_code",
65
+ "minder_search_errors",
66
+ "minder_search",
67
+ "minder_memory_recall",
68
+ "minder_workflow_get",
69
+ ],
70
+ "Full Dev Assistant": [
71
+ "minder_query",
72
+ "minder_search_code",
73
+ "minder_search_errors",
74
+ "minder_search",
75
+ "minder_memory_recall",
76
+ "minder_workflow_get",
77
+ "minder_workflow_step",
78
+ ],
79
+ }
80
+
81
+
82
+ class AdminConsoleUseCases:
83
+ def __init__(
84
+ self,
85
+ *,
86
+ store: IOperationalStore,
87
+ auth_service: AuthService,
88
+ config: MinderConfig,
89
+ graph_store: IGraphRepository | None = None,
90
+ ) -> None:
91
+ self._store = store
92
+ self._auth_service = auth_service
93
+ self._config = config
94
+ self._graph_store = graph_store
95
+ self._graph_tools = GraphTools(graph_store, store)
96
+
97
+ async def has_admin_users(self) -> bool:
98
+ return await self._auth_service.has_admin_users()
99
+
100
+ async def create_initial_admin(
101
+ self,
102
+ *,
103
+ username: str,
104
+ email: str,
105
+ display_name: str,
106
+ password: str | None = None,
107
+ ) -> SetupResultPayload:
108
+ _user, api_key = await self._auth_service.register_user(
109
+ email=email,
110
+ username=username,
111
+ display_name=display_name,
112
+ role="admin",
113
+ password=password,
114
+ )
115
+ return {"api_key": api_key}
116
+
117
+ async def login_admin(self, api_key: str) -> AdminLoginPayload:
118
+ """Authenticate via admin API key (``mk_...`` format)."""
119
+ user = await self._auth_service.authenticate_api_key(api_key)
120
+ if user.role != "admin":
121
+ raise PermissionError("Admin role required")
122
+ return {"jwt": self._auth_service.issue_jwt(user)}
123
+
124
+ async def login_admin_by_password(
125
+ self, username: str, password: str
126
+ ) -> AdminLoginPayload:
127
+ """Authenticate via username + password."""
128
+ user = await self._auth_service.authenticate_username_password(
129
+ username, password
130
+ )
131
+ if user.role != "admin":
132
+ raise PermissionError("Admin role required")
133
+ return {"jwt": self._auth_service.issue_jwt(user)}
134
+
135
+ async def set_user_password(self, user_id: uuid.UUID, password: str) -> None:
136
+ """Set or replace the login password for any user."""
137
+ await self._auth_service.set_password(user_id, password)
138
+
139
+ @staticmethod
140
+ def serialize_admin_session(user: Any) -> AdminSessionPayload:
141
+ return {
142
+ "id": str(user.id),
143
+ "username": str(user.username),
144
+ "email": str(user.email),
145
+ "display_name": str(user.display_name),
146
+ "role": str(user.role),
147
+ }
148
+
149
+ async def exchange_client_key(
150
+ self,
151
+ *,
152
+ client_api_key: str,
153
+ requested_scopes: list[str] | None = None,
154
+ ) -> dict[str, Any]:
155
+ return await self._auth_service.exchange_client_api_key(
156
+ client_api_key,
157
+ requested_scopes=requested_scopes,
158
+ )
159
+
160
+ def list_tools(self) -> list[dict[str, str]]:
161
+ """Return all tools that can be granted to client principals."""
162
+ return [
163
+ {"name": tool.name, "description": tool.description}
164
+ for tool in SCOPEABLE_TOOLS
165
+ ]
166
+
167
+ async def list_clients(self) -> ClientListPayload:
168
+ return {
169
+ "clients": [
170
+ self.serialize_client(client)
171
+ for client in await self._store.list_clients()
172
+ ]
173
+ }
174
+
175
+ async def create_client(
176
+ self,
177
+ *,
178
+ actor_user_id: uuid.UUID,
179
+ name: str,
180
+ slug: str,
181
+ description: str = "",
182
+ tool_scopes: list[str] | None = None,
183
+ repo_scopes: list[str] | None = None,
184
+ ) -> CreateClientPayload:
185
+ client, client_api_key = await self._auth_service.register_client(
186
+ name=name,
187
+ slug=slug,
188
+ description=description,
189
+ created_by_user_id=actor_user_id,
190
+ tool_scopes=tool_scopes,
191
+ repo_scopes=repo_scopes,
192
+ )
193
+ return {
194
+ "client": self.serialize_client(client),
195
+ "client_api_key": client_api_key,
196
+ }
197
+
198
+ async def get_client_detail(self, client_id: uuid.UUID) -> ClientDetailPayload:
199
+ client = await self._store.get_client_by_id(client_id)
200
+ if client is None:
201
+ raise LookupError("Client not found")
202
+ return {"client": self.serialize_client(client)}
203
+
204
+ async def update_client(
205
+ self,
206
+ *,
207
+ client_id: uuid.UUID,
208
+ name: str | None = None,
209
+ description: str | None = None,
210
+ repo_scopes: list[str] | None = None,
211
+ tool_scopes: list[str] | None = None,
212
+ ) -> ClientDetailPayload:
213
+ kwargs: dict[str, Any] = {}
214
+ if name is not None:
215
+ kwargs["name"] = name
216
+ if description is not None:
217
+ kwargs["description"] = description
218
+ if repo_scopes is not None:
219
+ kwargs["repo_scopes"] = repo_scopes
220
+ if tool_scopes is not None:
221
+ kwargs["tool_scopes"] = tool_scopes
222
+ updated = await self._store.update_client(client_id, **kwargs)
223
+ if updated is None:
224
+ raise LookupError("Client not found")
225
+ return {"client": self.serialize_client(updated)}
226
+
227
+ async def issue_client_key(
228
+ self,
229
+ *,
230
+ client_id: uuid.UUID,
231
+ actor_user_id: uuid.UUID,
232
+ ) -> ClientKeyPayload:
233
+ client_api_key = await self._auth_service.create_client_api_key(
234
+ client_id=client_id,
235
+ created_by_user_id=actor_user_id,
236
+ )
237
+ return {"client_api_key": client_api_key}
238
+
239
+ async def revoke_client_keys(
240
+ self,
241
+ *,
242
+ client_id: uuid.UUID,
243
+ actor_user_id: uuid.UUID,
244
+ ) -> RevokeKeysPayload:
245
+ await self._auth_service.revoke_client_api_keys(
246
+ client_id, actor_user_id=actor_user_id
247
+ )
248
+ return {"revoked": True}
249
+
250
+ async def get_onboarding(
251
+ self,
252
+ client_id: uuid.UUID,
253
+ *,
254
+ public_base_url: str | None = None,
255
+ ) -> OnboardingPayload:
256
+ client = await self._store.get_client_by_id(client_id)
257
+ if client is None:
258
+ raise LookupError("Client not found")
259
+ return {
260
+ "client": self.serialize_client(client),
261
+ "templates": self.onboarding_templates(
262
+ client, public_base_url=public_base_url
263
+ ),
264
+ }
265
+
266
+ async def test_client_connection(
267
+ self,
268
+ client_api_key: str,
269
+ *,
270
+ public_base_url: str | None = None,
271
+ ) -> ClientConnectionTestPayload:
272
+ client = await self._auth_service.authenticate_client_api_key(client_api_key)
273
+ return {
274
+ "ok": True,
275
+ "client": self.serialize_client(client),
276
+ "templates": self.onboarding_templates(
277
+ client, public_base_url=public_base_url
278
+ ),
279
+ }
280
+
281
+ async def list_audit(
282
+ self,
283
+ *,
284
+ actor_id: str | None = None,
285
+ event_type: str | None = None,
286
+ outcome: str | None = None,
287
+ limit: int = 50,
288
+ offset: int = 0,
289
+ ) -> AuditListPayload:
290
+ events = await self._store.list_audit_logs(
291
+ actor_id=actor_id,
292
+ event_type=event_type,
293
+ outcome=outcome,
294
+ limit=limit,
295
+ offset=offset,
296
+ )
297
+ total = await self._store.count_audit_logs(
298
+ actor_id=actor_id,
299
+ event_type=event_type,
300
+ outcome=outcome,
301
+ )
302
+ serialized = [
303
+ await self.serialize_audit_event_enriched(event) for event in events
304
+ ]
305
+ return {"events": serialized, "total": total, "limit": limit, "offset": offset}
306
+
307
+ async def get_recent_client_activity(
308
+ self,
309
+ client_id: uuid.UUID,
310
+ *,
311
+ limit: int = 8,
312
+ ) -> list[ActivityEventPayload]:
313
+ events = await self._store.list_audit_logs()
314
+ filtered = [
315
+ event
316
+ for event in events
317
+ if str(getattr(event, "resource_id", "")) == str(client_id)
318
+ ]
319
+ filtered.sort(
320
+ key=lambda event: getattr(event, "created_at", None) or "", reverse=True
321
+ )
322
+ return [
323
+ {
324
+ "event_type": str(getattr(event, "event_type", "")),
325
+ "created_at": (
326
+ getattr(event, "created_at").isoformat()
327
+ if getattr(event, "created_at", None)
328
+ else "unknown time"
329
+ ),
330
+ }
331
+ for event in filtered[:limit]
332
+ ]
333
+
334
+ async def list_repo_scope_candidates(self) -> list[str]:
335
+ candidates = ["*", "/workspace/repo", "/workspace/docs"]
336
+ clients = await self._store.list_clients()
337
+ for client in clients:
338
+ candidates.extend(list(getattr(client, "repo_scopes", [])))
339
+ return self.dedupe_preserve_order(candidates)
340
+
341
+ def onboarding_templates(
342
+ self, client: Any, *, public_base_url: str | None = None
343
+ ) -> dict[str, str]:
344
+ base_url = (
345
+ public_base_url.rstrip("/")
346
+ if public_base_url
347
+ else f"http://localhost:{self._config.server.port}"
348
+ )
349
+ return {
350
+ "codex": (
351
+ "[mcp_servers.minder]\n"
352
+ f'url = "{base_url}/sse"\n'
353
+ 'http_headers = { "X-Minder-Client-Key" = "<mkc_...>" }'
354
+ ),
355
+ "vscode": (
356
+ f'{{"servers":{{"minder":{{"type":"sse","url":"{base_url}/sse","headers":{{"X-Minder-Client-Key":"<mkc_...>"}}}}}},"inputs":[]}}'
357
+ ),
358
+ "copilot_cli": (
359
+ f'{{"mcpServers":{{"minder":{{"type":"sse","url":"{base_url}/sse","headers":{{"X-Minder-Client-Key":"<mkc_...>"}},"tools":["*"]}}}}}}'
360
+ ),
361
+ "antigravity": (
362
+ f'{{"mcpServers":{{"minder":{{"serverUrl":"{base_url}/mcp","headers":{{"X-Minder-Client-Key":"<mkc_...>"}}}}}}}}'
363
+ ),
364
+ "cursor": (
365
+ f'{{"mcpServers":{{"minder":{{"url":"{base_url}/mcp","headers":{{"X-Minder-Client-Key":"<mkc_...>"}}}}}}}}'
366
+ ),
367
+ "claude_code": (
368
+ f'{{"mcpServers":{{"minder":{{"type":"sse","url":"{base_url}/sse","headers":{{"X-Minder-Client-Key":"<mkc_...>"}}}}}}}}'
369
+ ),
370
+ }
371
+
372
+ @staticmethod
373
+ def split_csv(raw: str) -> list[str]:
374
+ return [item.strip() for item in raw.split(",") if item.strip()]
375
+
376
+ @staticmethod
377
+ def dedupe_preserve_order(values: list[str]) -> list[str]:
378
+ seen: set[str] = set()
379
+ deduped: list[str] = []
380
+ for value in values:
381
+ if value not in seen:
382
+ seen.add(value)
383
+ deduped.append(value)
384
+ return deduped
385
+
386
+ @staticmethod
387
+ def serialize_client(client: Any) -> ClientPayload:
388
+ return {
389
+ "id": str(client.id),
390
+ "name": client.name,
391
+ "slug": client.slug,
392
+ "description": getattr(client, "description", ""),
393
+ "status": client.status,
394
+ "tool_scopes": list(client.tool_scopes),
395
+ "repo_scopes": list(client.repo_scopes),
396
+ "workflow_scopes": list(getattr(client, "workflow_scopes", [])),
397
+ "transport_modes": list(getattr(client, "transport_modes", [])),
398
+ }
399
+
400
+ @staticmethod
401
+ def serialize_audit_event(event: Any) -> AuditEventPayload:
402
+ return {
403
+ "id": str(event.id),
404
+ "actor_type": event.actor_type,
405
+ "actor_id": event.actor_id,
406
+ "actor_name": None,
407
+ "event_type": event.event_type,
408
+ "resource_type": event.resource_type,
409
+ "resource_id": event.resource_id,
410
+ "resource_name": None,
411
+ "outcome": event.outcome,
412
+ "created_at": event.created_at.isoformat() if event.created_at else None,
413
+ }
414
+
415
+ async def serialize_audit_event_enriched(self, event: Any) -> AuditEventPayload:
416
+ """Like serialize_audit_event but resolves human-readable names."""
417
+ base = self.serialize_audit_event(event)
418
+
419
+ # Resolve actor name
420
+ try:
421
+ if event.actor_type == "admin_user":
422
+ actor = await self._store.get_user_by_id(uuid.UUID(event.actor_id))
423
+ if actor:
424
+ base["actor_name"] = getattr(
425
+ actor, "display_name", None
426
+ ) or getattr(actor, "username", None)
427
+ elif event.actor_type == "client":
428
+ actor_client = await self._store.get_client_by_id(
429
+ uuid.UUID(event.actor_id)
430
+ )
431
+ if actor_client:
432
+ base["actor_name"] = getattr(actor_client, "name", None)
433
+ except Exception:
434
+ pass
435
+
436
+ # Resolve resource name
437
+ try:
438
+ if event.resource_type == "client":
439
+ resource_client = await self._store.get_client_by_id(
440
+ uuid.UUID(event.resource_id)
441
+ )
442
+ if resource_client:
443
+ base["resource_name"] = getattr(resource_client, "name", None)
444
+ elif event.resource_type == "user":
445
+ resource_user = await self._store.get_user_by_id(
446
+ uuid.UUID(event.resource_id)
447
+ )
448
+ if resource_user:
449
+ base["resource_name"] = getattr(
450
+ resource_user, "display_name", None
451
+ ) or getattr(resource_user, "username", None)
452
+ except Exception:
453
+ pass
454
+
455
+ return base
456
+
457
+ # ------------------------------------------------------------------
458
+ # User management
459
+ # ------------------------------------------------------------------
460
+
461
+ async def list_users(self, *, active_only: bool = False) -> UserListPayload:
462
+ users = await self._store.list_users(active_only=active_only)
463
+ return {"users": [self.serialize_user(u) for u in users]}
464
+
465
+ async def create_user(
466
+ self,
467
+ *,
468
+ username: str,
469
+ email: str,
470
+ display_name: str,
471
+ role: str = "admin",
472
+ password: str | None = None,
473
+ ) -> CreateUserPayload:
474
+ user, api_key = await self._auth_service.register_user(
475
+ email=email,
476
+ username=username,
477
+ display_name=display_name,
478
+ role=role,
479
+ password=password,
480
+ )
481
+ return {"user": self.serialize_user(user), "api_key": api_key}
482
+
483
+ async def get_user_detail(self, user_id: uuid.UUID) -> UserDetailPayload:
484
+ user = await self._store.get_user_by_id(user_id)
485
+ if user is None:
486
+ raise LookupError(f"User {user_id} not found")
487
+ # Include MCP clients created by this user
488
+ all_clients = await self._store.list_clients()
489
+ owned_clients = [
490
+ self.serialize_client(c)
491
+ for c in all_clients
492
+ if str(getattr(c, "created_by_user_id", "")) == str(user_id)
493
+ ]
494
+ return {"user": self.serialize_user(user), "clients": owned_clients}
495
+
496
+ async def update_user(
497
+ self,
498
+ user_id: uuid.UUID,
499
+ *,
500
+ role: str | None = None,
501
+ is_active: bool | None = None,
502
+ display_name: str | None = None,
503
+ ) -> UserDetailPayload:
504
+ kwargs: dict[str, Any] = {}
505
+ if role is not None:
506
+ kwargs["role"] = role
507
+ if is_active is not None:
508
+ kwargs["is_active"] = is_active
509
+ if display_name is not None:
510
+ kwargs["display_name"] = display_name
511
+ updated = await self._store.update_user(user_id, **kwargs)
512
+ if updated is None:
513
+ raise LookupError(f"User {user_id} not found")
514
+ return await self.get_user_detail(user_id)
515
+
516
+ async def deactivate_user(self, user_id: uuid.UUID) -> UserDetailPayload:
517
+ await self._store.update_user(user_id, is_active=False)
518
+ return await self.get_user_detail(user_id)
519
+
520
+ @staticmethod
521
+ def serialize_user(user: Any) -> UserPayload:
522
+ return {
523
+ "id": str(user.id),
524
+ "username": user.username,
525
+ "email": user.email,
526
+ "display_name": getattr(user, "display_name", user.username),
527
+ "role": user.role,
528
+ "is_active": bool(getattr(user, "is_active", True)),
529
+ "created_at": (
530
+ user.created_at.isoformat()
531
+ if getattr(user, "created_at", None)
532
+ else None
533
+ ),
534
+ }
535
+
536
+ # ------------------------------------------------------------------
537
+ # Workflow management
538
+ # ------------------------------------------------------------------
539
+
540
+ async def list_workflows(self) -> WorkflowListPayload:
541
+ workflows = await self._store.list_workflows()
542
+ return {"workflows": [self.serialize_workflow(w) for w in workflows]}
543
+
544
+ async def get_workflow_detail(
545
+ self, workflow_id: uuid.UUID
546
+ ) -> WorkflowDetailPayload:
547
+ workflow = await self._store.get_workflow_by_id(workflow_id)
548
+ if workflow is None:
549
+ raise LookupError(f"Workflow {workflow_id} not found")
550
+ return {"workflow": self.serialize_workflow(workflow)}
551
+
552
+ async def create_workflow(
553
+ self,
554
+ *,
555
+ name: str,
556
+ description: str = "",
557
+ enforcement: str = "strict",
558
+ steps: list[dict[str, Any]] | None = None,
559
+ ) -> WorkflowDetailPayload:
560
+ workflow = await self._store.create_workflow(
561
+ id=uuid.uuid4(),
562
+ name=name,
563
+ description=description,
564
+ enforcement=enforcement,
565
+ steps=steps or [],
566
+ )
567
+ return {"workflow": self.serialize_workflow(workflow)}
568
+
569
+ async def update_workflow(
570
+ self,
571
+ workflow_id: uuid.UUID,
572
+ *,
573
+ name: str | None = None,
574
+ description: str | None = None,
575
+ enforcement: str | None = None,
576
+ steps: list[dict[str, Any]] | None = None,
577
+ ) -> WorkflowDetailPayload:
578
+ kwargs: dict[str, Any] = {}
579
+ if name is not None:
580
+ kwargs["name"] = name
581
+ if description is not None:
582
+ kwargs["description"] = description
583
+ if enforcement is not None:
584
+ kwargs["enforcement"] = enforcement
585
+ if steps is not None:
586
+ kwargs["steps"] = steps
587
+ updated = await self._store.update_workflow(workflow_id, **kwargs)
588
+ if updated is None:
589
+ raise LookupError(f"Workflow {workflow_id} not found")
590
+ return {"workflow": self.serialize_workflow(updated)}
591
+
592
+ async def delete_workflow(self, workflow_id: uuid.UUID) -> dict[str, bool]:
593
+ existing = await self._store.get_workflow_by_id(workflow_id)
594
+ if existing is None:
595
+ raise LookupError(f"Workflow {workflow_id} not found")
596
+ await self._store.delete_workflow(workflow_id)
597
+ return {"deleted": True}
598
+
599
+ @staticmethod
600
+ def serialize_workflow(workflow: Any) -> WorkflowPayload:
601
+ raw_steps = getattr(workflow, "steps", []) or []
602
+ steps: list[WorkflowStepPayload] = [
603
+ {
604
+ "name": s.get("name", "") if isinstance(s, dict) else str(s),
605
+ "description": s.get("description", "") if isinstance(s, dict) else "",
606
+ "gate": s.get("gate", None) if isinstance(s, dict) else None,
607
+ }
608
+ for s in raw_steps
609
+ ]
610
+ return {
611
+ "id": str(workflow.id),
612
+ "name": workflow.name,
613
+ "description": getattr(workflow, "description", ""),
614
+ "enforcement": getattr(workflow, "enforcement", "strict"),
615
+ "steps": steps,
616
+ "created_at": (
617
+ workflow.created_at.isoformat()
618
+ if getattr(workflow, "created_at", None)
619
+ else None
620
+ ),
621
+ }
622
+
623
+ # ------------------------------------------------------------------
624
+ # Repository management
625
+ # ------------------------------------------------------------------
626
+
627
+ async def list_repositories(self) -> RepositoryListPayload:
628
+ repos = await self._store.list_repositories()
629
+ result: list[RepositoryPayload] = []
630
+ for repo in repos:
631
+ state = None
632
+ try:
633
+ state = await self._store.get_workflow_state_by_repo(repo.id)
634
+ except Exception:
635
+ pass
636
+ result.append(self.serialize_repository(repo, state))
637
+ return {"repositories": result}
638
+
639
+ async def get_repository_detail(
640
+ self, repo_id: uuid.UUID
641
+ ) -> RepositoryDetailPayload:
642
+ repository = await self._store.get_repository_by_id(repo_id)
643
+ if repository is None:
644
+ raise LookupError("Repository not found")
645
+
646
+ state = None
647
+ try:
648
+ state = await self._store.get_workflow_state_by_repo(repo_id)
649
+ except Exception:
650
+ state = None
651
+ return {"repository": self.serialize_repository(repository, state)}
652
+
653
+ async def update_repository(
654
+ self,
655
+ *,
656
+ repo_id: uuid.UUID,
657
+ name: str | None = None,
658
+ remote_url: str | None = None,
659
+ default_branch: str | None = None,
660
+ path: str | None = None,
661
+ ) -> RepositoryDetailPayload:
662
+ repository = await self._store.get_repository_by_id(repo_id)
663
+ if repository is None:
664
+ raise LookupError("Repository not found")
665
+
666
+ updates: dict[str, Any] = {}
667
+ if name is not None:
668
+ normalized_name = str(name).strip()
669
+ if not normalized_name:
670
+ raise ValueError("Repository name is required")
671
+ updates["repo_name"] = normalized_name
672
+ if remote_url is not None:
673
+ normalized_remote = _normalize_repository_remote(remote_url)
674
+ if normalized_remote is None:
675
+ raise ValueError("Repository remote URL is required")
676
+ updates["repo_url"] = normalized_remote
677
+ if default_branch is not None:
678
+ normalized_branch = str(default_branch).strip()
679
+ if not normalized_branch:
680
+ raise ValueError("Default branch is required")
681
+ updates["default_branch"] = normalized_branch
682
+ if path is not None:
683
+ normalized_path = str(path).strip()
684
+ if not normalized_path:
685
+ raise ValueError("Repository path is required")
686
+ updates["state_path"] = normalized_path
687
+
688
+ updated = await self._store.update_repository(repo_id, **updates)
689
+ if updated is None:
690
+ raise LookupError("Repository not found")
691
+ return await self.get_repository_detail(repo_id)
692
+
693
+ async def delete_repository(self, repo_id: uuid.UUID) -> DeleteRepositoryPayload:
694
+ repository = await self._store.get_repository_by_id(repo_id)
695
+ if repository is None:
696
+ raise LookupError("Repository not found")
697
+ await self._store.delete_repository(repo_id)
698
+ return {"deleted": True}
699
+
700
+ async def resolve_repository_for_client(
701
+ self,
702
+ *,
703
+ repo_name: str,
704
+ repo_path: str,
705
+ repo_url: str | None = None,
706
+ default_branch: str | None = None,
707
+ ) -> ClientRepositoryResolvePayload:
708
+ normalized_url = _normalize_repository_remote(repo_url)
709
+ if normalized_url is None:
710
+ raise ValueError(
711
+ "Repository remote SSH URL is required for repository resolution"
712
+ )
713
+
714
+ normalized_name = _repo_name_from_remote(normalized_url) or repo_name.strip()
715
+ normalized_path = repo_path.strip().rstrip("/")
716
+ normalized_branch = (default_branch or "").strip() or "main"
717
+
718
+ if not normalized_name:
719
+ raise ValueError("Repository name is required")
720
+ if not normalized_path:
721
+ raise ValueError("Repository path is required")
722
+
723
+ repository = await self._find_repository_for_client_sync(
724
+ repo_name=normalized_name,
725
+ repo_path=normalized_path,
726
+ repo_url=normalized_url,
727
+ )
728
+ state_path = str(Path(normalized_path) / self._config.workflow.repo_state_dir)
729
+ created = False
730
+
731
+ if repository is None:
732
+ repository = await self._store.create_repository(
733
+ repo_name=normalized_name,
734
+ repo_url=normalized_url,
735
+ default_branch=normalized_branch,
736
+ state_path=state_path,
737
+ )
738
+ created = True
739
+ else:
740
+ updates: dict[str, Any] = {}
741
+ existing_remote = _normalize_repository_remote(
742
+ getattr(repository, "repo_url", None)
743
+ )
744
+ if existing_remote != normalized_url:
745
+ updates["repo_url"] = normalized_url
746
+ if str(getattr(repository, "state_path", "") or "") != state_path:
747
+ updates["state_path"] = state_path
748
+ if (
749
+ normalized_branch
750
+ and str(getattr(repository, "default_branch", "") or "")
751
+ != normalized_branch
752
+ ):
753
+ updates["default_branch"] = normalized_branch
754
+ if updates:
755
+ repository = (
756
+ await self._store.update_repository(repository.id, **updates)
757
+ or repository
758
+ )
759
+
760
+ return {
761
+ "repository": self.serialize_repository(repository),
762
+ "created": created,
763
+ }
764
+
765
+ @staticmethod
766
+ def serialize_repository(repo: Any, state: Any = None) -> RepositoryPayload:
767
+ raw_branches = getattr(repo, "tracked_branches", None)
768
+ tracked: list[str] = (
769
+ list(raw_branches) if isinstance(raw_branches, list) else []
770
+ )
771
+ return {
772
+ "id": str(repo.id),
773
+ "name": getattr(repo, "repo_name", getattr(repo, "name", "")),
774
+ "path": getattr(repo, "state_path", getattr(repo, "path", "")),
775
+ "remote_url": _normalize_repository_remote(getattr(repo, "repo_url", None)),
776
+ "default_branch": getattr(repo, "default_branch", None),
777
+ "tracked_branches": tracked,
778
+ "workflow_name": getattr(state, "workflow_name", None) if state else None,
779
+ "workflow_state": getattr(state, "state", None) if state else None,
780
+ "current_step": getattr(state, "current_step", None) if state else None,
781
+ "created_at": (
782
+ repo.created_at.isoformat()
783
+ if getattr(repo, "created_at", None)
784
+ else None
785
+ ),
786
+ }
787
+
788
+ async def sync_repository_graph(
789
+ self,
790
+ *,
791
+ repo_id: uuid.UUID,
792
+ payload: GraphSyncRequest,
793
+ ) -> GraphSyncResultPayload:
794
+ if self._graph_store is None:
795
+ raise RuntimeError("Graph sync store is not configured")
796
+
797
+ repository = await self._store.get_repository_by_id(repo_id)
798
+ if repository is None:
799
+ raise LookupError("Repository not found")
800
+
801
+ repo_name = getattr(
802
+ repository, "repo_name", getattr(repository, "name", str(repo_id))
803
+ )
804
+ repo_remote = _normalize_repository_remote(
805
+ getattr(repository, "repo_url", None)
806
+ )
807
+ branch = payload.branch or getattr(repository, "default_branch", None)
808
+ accepted_at = datetime.now(UTC).isoformat()
809
+ node_ids: dict[tuple[str, str], uuid.UUID] = {}
810
+ deleted_nodes = 0
811
+ nodes_upserted = 0
812
+ edges_upserted = 0
813
+
814
+ # --- Scoped deletion: prune stale nodes for changed/deleted files ---
815
+ changed_files = payload.sync_metadata.get("changed_files", [])
816
+ paths_to_prune: set[str] = set(payload.deleted_files)
817
+ if isinstance(changed_files, list):
818
+ paths_to_prune.update(
819
+ str(p) for p in changed_files if isinstance(p, str) and p.strip()
820
+ )
821
+ paths_to_prune.update(
822
+ str(node.metadata.get("path"))
823
+ for node in payload.nodes
824
+ if isinstance(node.metadata.get("path"), str)
825
+ and str(node.metadata.get("path")).strip()
826
+ )
827
+
828
+ if paths_to_prune:
829
+ # Use efficient scoped deletion (v2) or fallback to full scan
830
+ if hasattr(self._graph_store, "delete_nodes_by_scope"):
831
+ deleted_nodes = await self._graph_store.delete_nodes_by_scope(
832
+ repo_id=str(repo_id),
833
+ branch=branch,
834
+ paths=paths_to_prune,
835
+ )
836
+ else:
837
+ for graph_node in await self._graph_store.list_nodes():
838
+ metadata = dict(getattr(graph_node, "node_metadata", {}) or {})
839
+ if metadata.get("repo_id") != str(repo_id):
840
+ continue
841
+ if branch is not None and metadata.get("branch") not in {
842
+ None,
843
+ branch,
844
+ }:
845
+ continue
846
+ if str(metadata.get("path", "") or "") not in paths_to_prune:
847
+ continue
848
+ await self._graph_store.delete_node(graph_node.id)
849
+ deleted_nodes += 1
850
+
851
+ # --- Upsert nodes with proper repo/branch scope (v2) ---
852
+ _branch = branch or ""
853
+ _repo_id_str = str(repo_id)
854
+ _common_meta = {
855
+ "repo_id": _repo_id_str,
856
+ "repository_name": repo_name,
857
+ "repository_remote": repo_remote,
858
+ "source": payload.source,
859
+ "payload_version": payload.payload_version,
860
+ "branch": _branch,
861
+ "repo_path": payload.repo_path,
862
+ "diff_base": payload.diff_base,
863
+ **payload.sync_metadata,
864
+ }
865
+
866
+ for node in payload.nodes:
867
+ persisted = await self._graph_store.upsert_node(
868
+ node.node_type,
869
+ node.name,
870
+ metadata={**_common_meta, **node.metadata},
871
+ repo_id=_repo_id_str,
872
+ branch=_branch,
873
+ )
874
+ node_ids[(node.node_type, node.name)] = persisted.id
875
+ nodes_upserted += 1
876
+
877
+ _edge_common_meta = {
878
+ "repo_id": _repo_id_str,
879
+ "repository_name": repo_name,
880
+ "repository_remote": repo_remote,
881
+ "source": payload.source,
882
+ "payload_version": payload.payload_version,
883
+ "branch": _branch,
884
+ "repo_path": payload.repo_path,
885
+ }
886
+
887
+ for edge in payload.edges:
888
+ source_key = (edge.source.node_type, edge.source.name)
889
+ target_key = (edge.target.node_type, edge.target.name)
890
+
891
+ if source_key not in node_ids:
892
+ source_node = await self._graph_store.upsert_node(
893
+ edge.source.node_type,
894
+ edge.source.name,
895
+ metadata=_edge_common_meta,
896
+ repo_id=_repo_id_str,
897
+ branch=_branch,
898
+ )
899
+ node_ids[source_key] = source_node.id
900
+ nodes_upserted += 1
901
+
902
+ if target_key not in node_ids:
903
+ target_node = await self._graph_store.upsert_node(
904
+ edge.target.node_type,
905
+ edge.target.name,
906
+ metadata=_edge_common_meta,
907
+ repo_id=_repo_id_str,
908
+ branch=_branch,
909
+ )
910
+ node_ids[target_key] = target_node.id
911
+ nodes_upserted += 1
912
+
913
+ await self._graph_store.upsert_edge(
914
+ source_id=node_ids[source_key],
915
+ target_id=node_ids[target_key],
916
+ relation=edge.relation,
917
+ weight=edge.weight,
918
+ repo_id=_repo_id_str,
919
+ )
920
+ edges_upserted += 1
921
+
922
+ # --- Update repository: tracked_branches + graph_sync metadata ---
923
+ relationships = dict(getattr(repository, "relationships", {}) or {})
924
+ graph_sync_state = {
925
+ "payload_version": payload.payload_version,
926
+ "source": payload.source,
927
+ "branch": branch,
928
+ "repo_path": payload.repo_path,
929
+ "repo_remote": repo_remote,
930
+ "diff_base": payload.diff_base,
931
+ "deleted_files": payload.deleted_files,
932
+ "deleted_nodes": deleted_nodes,
933
+ "nodes_upserted": nodes_upserted,
934
+ "edges_upserted": edges_upserted,
935
+ "accepted_at": accepted_at,
936
+ }
937
+ graph_sync = dict(relationships.get("graph_sync", {}) or {})
938
+ graph_sync["last_sync"] = graph_sync_state
939
+ graph_sync.update(graph_sync_state)
940
+ branch_registry = dict(graph_sync.get("branches", {}) or {})
941
+ if branch:
942
+ branch_registry[branch] = graph_sync_state
943
+ graph_sync["branches"] = branch_registry
944
+ relationships["graph_sync"] = graph_sync
945
+
946
+ cross_repo_links = self._repository_branch_links(repository)
947
+ if payload.branch_relationships:
948
+ repositories = await self._store.list_repositories()
949
+ cross_repo_links = self._merge_branch_links(
950
+ cross_repo_links,
951
+ self._build_branch_links(
952
+ repository=repository,
953
+ repositories=repositories,
954
+ source_branch=branch,
955
+ accepted_at=accepted_at,
956
+ source=payload.source,
957
+ specs=[
958
+ {
959
+ "source_branch": relationship.source_branch,
960
+ "target_repo_id": relationship.target_repo_id,
961
+ "target_repo_name": relationship.target_repo_name,
962
+ "target_repo_url": relationship.target_repo_url,
963
+ "target_branch": relationship.target_branch,
964
+ "relation": relationship.relation,
965
+ "direction": relationship.direction,
966
+ "confidence": relationship.confidence,
967
+ "metadata": relationship.metadata,
968
+ }
969
+ for relationship in payload.branch_relationships
970
+ ],
971
+ ),
972
+ )
973
+ relationships["cross_repo_branches"] = cross_repo_links
974
+
975
+ # Auto-register branch in tracked_branches on first sync
976
+ raw_branches = list(getattr(repository, "tracked_branches", None) or [])
977
+ if branch:
978
+ if branch not in raw_branches:
979
+ raw_branches.append(branch)
980
+ await self._store.update_repository(
981
+ repo_id,
982
+ relationships=relationships,
983
+ tracked_branches=raw_branches,
984
+ )
985
+
986
+ return {
987
+ "repo_id": str(repo_id),
988
+ "repository_name": repo_name,
989
+ "payload_version": payload.payload_version,
990
+ "source": payload.source,
991
+ "branch": branch,
992
+ "deleted_nodes": deleted_nodes,
993
+ "nodes_upserted": nodes_upserted,
994
+ "edges_upserted": edges_upserted,
995
+ "accepted_at": accepted_at,
996
+ }
997
+
998
+ async def get_repository_graph_summary(
999
+ self,
1000
+ *,
1001
+ repo_id: uuid.UUID,
1002
+ branch: str | None = None,
1003
+ ) -> RepositoryGraphSummaryPayload:
1004
+ repository = await self._store.get_repository_by_id(repo_id)
1005
+ if repository is None:
1006
+ raise LookupError("Repository not found")
1007
+
1008
+ repository_payload = self.serialize_repository(repository)
1009
+ effective_branch = branch or getattr(repository, "default_branch", None) or None
1010
+ branch_state = self._repository_branch_state_payload(
1011
+ repository, effective_branch
1012
+ )
1013
+ branch_links = await self.list_repository_branch_links(
1014
+ repo_id=repo_id, branch=effective_branch
1015
+ )
1016
+ if self._graph_store is None:
1017
+ return {
1018
+ "repository": repository_payload,
1019
+ "graph_available": False,
1020
+ "active_branch": effective_branch,
1021
+ "branch_state": branch_state,
1022
+ "branch_links": branch_links["links"],
1023
+ "last_sync": self._repository_last_sync(repository),
1024
+ "node_count": 0,
1025
+ "counts_by_type": {},
1026
+ "routes": [],
1027
+ "todos": [],
1028
+ "external_services": [],
1029
+ "dependencies": [],
1030
+ }
1031
+
1032
+ repo_nodes = await self._repository_graph_nodes(repository, branch=branch)
1033
+ counts = Counter(str(getattr(node, "node_type", "")) for node in repo_nodes)
1034
+ repo_node_ids = {str(getattr(node, "id")) for node in repo_nodes}
1035
+ services = [
1036
+ node
1037
+ for node in repo_nodes
1038
+ if str(getattr(node, "node_type", "")) == "service"
1039
+ ]
1040
+ dependencies: list[dict[str, Any]] = []
1041
+ for service in services:
1042
+ neighbors = await self._graph_store.get_neighbors(
1043
+ getattr(service, "id"),
1044
+ direction="out",
1045
+ relation="depends_on",
1046
+ )
1047
+ targets = [
1048
+ {
1049
+ "id": str(getattr(neighbor, "id")),
1050
+ "name": str(getattr(neighbor, "name", "")),
1051
+ "node_type": str(getattr(neighbor, "node_type", "")),
1052
+ }
1053
+ for neighbor in neighbors
1054
+ if str(getattr(neighbor, "id")) in repo_node_ids
1055
+ ]
1056
+ if targets:
1057
+ dependencies.append(
1058
+ {
1059
+ "service": str(getattr(service, "name", "")),
1060
+ "depends_on": sorted(targets, key=lambda item: item["name"]),
1061
+ }
1062
+ )
1063
+
1064
+ return {
1065
+ "repository": repository_payload,
1066
+ "graph_available": True,
1067
+ "active_branch": effective_branch,
1068
+ "branch_state": branch_state,
1069
+ "branch_links": branch_links["links"],
1070
+ "last_sync": self._repository_last_sync(repository),
1071
+ "node_count": len(repo_nodes),
1072
+ "counts_by_type": dict(counts),
1073
+ "routes": self._serialize_repo_graph_nodes(
1074
+ repo_nodes, allowed_types={"route"}, limit=12
1075
+ ),
1076
+ "todos": self._serialize_repo_graph_nodes(
1077
+ repo_nodes, allowed_types={"todo"}, limit=12
1078
+ ),
1079
+ "external_services": self._serialize_repo_graph_nodes(
1080
+ repo_nodes, allowed_types={"external_service_api"}, limit=12
1081
+ ),
1082
+ "dependencies": dependencies,
1083
+ }
1084
+
1085
+ async def get_repository_graph_map(
1086
+ self,
1087
+ *,
1088
+ repo_id: uuid.UUID,
1089
+ branch: str | None = None,
1090
+ ) -> RepositoryGraphMapPayload:
1091
+ repository = await self._store.get_repository_by_id(repo_id)
1092
+ if repository is None:
1093
+ raise LookupError("Repository not found")
1094
+
1095
+ # Default to the repo's default_branch when no branch is specified
1096
+ effective_branch = branch or getattr(repository, "default_branch", None) or None
1097
+
1098
+ repository_payload = self.serialize_repository(repository)
1099
+ branch_state = self._repository_branch_state_payload(
1100
+ repository, effective_branch
1101
+ )
1102
+ branch_links = await self.list_repository_branch_links(
1103
+ repo_id=repo_id, branch=effective_branch
1104
+ )
1105
+ if self._graph_store is None:
1106
+ return {
1107
+ "repository": repository_payload,
1108
+ "graph_available": False,
1109
+ "branch": effective_branch,
1110
+ "branch_state": branch_state,
1111
+ "branch_links": branch_links["links"],
1112
+ "nodes": [],
1113
+ "edges": [],
1114
+ "summary": {
1115
+ "node_count": 0,
1116
+ "edge_count": 0,
1117
+ "counts_by_type": {},
1118
+ "counts_by_relation": {},
1119
+ },
1120
+ }
1121
+
1122
+ _, repo_nodes, repo_edges = await self._graph_tools.list_repo_graph(
1123
+ repo_id=str(repo_id),
1124
+ repo_name=getattr(repository, "repo_name", None),
1125
+ repo_path=self._repository_root_path(repository),
1126
+ branch=effective_branch,
1127
+ )
1128
+ node_counts = Counter(
1129
+ str(getattr(node, "node_type", "")) for node in repo_nodes
1130
+ )
1131
+ relation_counts = Counter(
1132
+ str(getattr(edge, "relation", "")) for edge in repo_edges
1133
+ )
1134
+ return {
1135
+ "repository": repository_payload,
1136
+ "graph_available": bool(repo_nodes),
1137
+ "branch": effective_branch,
1138
+ "branch_state": branch_state,
1139
+ "branch_links": branch_links["links"],
1140
+ "nodes": [self._serialize_graph_node(node) for node in repo_nodes],
1141
+ "edges": [self._serialize_graph_edge(edge) for edge in repo_edges],
1142
+ "summary": {
1143
+ "node_count": len(repo_nodes),
1144
+ "edge_count": len(repo_edges),
1145
+ "counts_by_type": dict(node_counts),
1146
+ "counts_by_relation": dict(relation_counts),
1147
+ },
1148
+ }
1149
+
1150
+ async def search_repository_graph(
1151
+ self,
1152
+ *,
1153
+ repo_id: uuid.UUID,
1154
+ query: str,
1155
+ branch: str | None = None,
1156
+ node_types: list[str] | None = None,
1157
+ languages: list[str] | None = None,
1158
+ last_states: list[str] | None = None,
1159
+ limit: int = 10,
1160
+ ) -> RepositoryGraphSearchPayload:
1161
+ repository = await self._store.get_repository_by_id(repo_id)
1162
+ if repository is None:
1163
+ raise LookupError("Repository not found")
1164
+ if self._graph_store is None:
1165
+ raise RuntimeError("Graph sync store is not configured")
1166
+
1167
+ result = await self._graph_tools.minder_search_graph(
1168
+ query,
1169
+ repo_id=str(repo_id),
1170
+ repo_name=getattr(repository, "repo_name", None),
1171
+ repo_path=self._repository_root_path(repository),
1172
+ branch=branch,
1173
+ node_types=node_types,
1174
+ languages=languages,
1175
+ last_states=last_states,
1176
+ limit=limit,
1177
+ include_linked_repos=True,
1178
+ )
1179
+ searched_scopes = result.get("searched_scopes", [])
1180
+ active_branch = branch
1181
+ if searched_scopes:
1182
+ active_branch = searched_scopes[0].get("branch")
1183
+ return {
1184
+ "repository": self.serialize_repository(repository),
1185
+ "active_branch": active_branch,
1186
+ "query": query,
1187
+ "filters": result["filters"],
1188
+ "scope_count": int(result.get("scope_count", len(searched_scopes))),
1189
+ "searched_scopes": searched_scopes,
1190
+ "count": result["count"],
1191
+ "results": result["results"],
1192
+ }
1193
+
1194
+ async def get_repository_graph_impact(
1195
+ self,
1196
+ *,
1197
+ repo_id: uuid.UUID,
1198
+ target: str,
1199
+ branch: str | None = None,
1200
+ depth: int = 2,
1201
+ limit: int = 25,
1202
+ ) -> RepositoryGraphImpactPayload:
1203
+ repository = await self._store.get_repository_by_id(repo_id)
1204
+ if repository is None:
1205
+ raise LookupError("Repository not found")
1206
+ if self._graph_store is None:
1207
+ raise RuntimeError("Graph sync store is not configured")
1208
+
1209
+ result = await self._graph_tools.minder_find_impact(
1210
+ target,
1211
+ repo_id=str(repo_id),
1212
+ repo_name=getattr(repository, "repo_name", None),
1213
+ repo_path=self._repository_root_path(repository),
1214
+ branch=branch,
1215
+ depth=depth,
1216
+ limit=limit,
1217
+ include_linked_repos=True,
1218
+ )
1219
+ searched_scopes = result.get("searched_scopes", [])
1220
+ active_branch = branch
1221
+ if searched_scopes:
1222
+ active_branch = searched_scopes[0].get("branch")
1223
+ return {
1224
+ "repository": self.serialize_repository(repository),
1225
+ "active_branch": active_branch,
1226
+ "target": target,
1227
+ "searched_scopes": searched_scopes,
1228
+ "matches": result["matches"],
1229
+ "impacted": result["impacted"],
1230
+ "summary": result["summary"],
1231
+ }
1232
+
1233
+ # ------------------------------------------------------------------
1234
+ # Branch management
1235
+ # ------------------------------------------------------------------
1236
+
1237
+ async def list_repository_branches(
1238
+ self,
1239
+ *,
1240
+ repo_id: uuid.UUID,
1241
+ ) -> RepositoryBranchListPayload:
1242
+ repository = await self._store.get_repository_by_id(repo_id)
1243
+ if repository is None:
1244
+ raise LookupError("Repository not found")
1245
+
1246
+ tracked_branches: list[RepositoryBranchPayload] = []
1247
+ for branch_name in self._repository_branch_names(repository):
1248
+ branch_state = self._repository_branch_state_payload(
1249
+ repository, branch_name
1250
+ )
1251
+ if branch_state is not None:
1252
+ tracked_branches.append(branch_state)
1253
+
1254
+ return {
1255
+ "repo_id": str(repo_id),
1256
+ "default_branch": getattr(repository, "default_branch", None),
1257
+ "tracked_branches": tracked_branches,
1258
+ }
1259
+
1260
+ async def add_repository_branch(
1261
+ self,
1262
+ *,
1263
+ repo_id: uuid.UUID,
1264
+ branch: str,
1265
+ ) -> "RepositoryBranchListPayload":
1266
+ branch = branch.strip()
1267
+ if not branch:
1268
+ raise ValueError("Branch name is required")
1269
+
1270
+ repository = await self._store.get_repository_by_id(repo_id)
1271
+ if repository is None:
1272
+ raise LookupError("Repository not found")
1273
+
1274
+ raw_branches = list(getattr(repository, "tracked_branches", None) or [])
1275
+ if branch not in raw_branches:
1276
+ raw_branches.append(branch)
1277
+ await self._store.update_repository(repo_id, tracked_branches=raw_branches)
1278
+
1279
+ return await self.list_repository_branches(repo_id=repo_id)
1280
+
1281
+ async def remove_repository_branch(
1282
+ self,
1283
+ *,
1284
+ repo_id: uuid.UUID,
1285
+ branch: str,
1286
+ ) -> "RepositoryBranchListPayload":
1287
+ repository = await self._store.get_repository_by_id(repo_id)
1288
+ if repository is None:
1289
+ raise LookupError("Repository not found")
1290
+
1291
+ default_branch = getattr(repository, "default_branch", None)
1292
+ if branch == default_branch:
1293
+ raise ValueError("Cannot remove the default branch")
1294
+
1295
+ raw_branches = list(getattr(repository, "tracked_branches", None) or [])
1296
+ raw_branches = [b for b in raw_branches if b != branch]
1297
+ await self._store.update_repository(repo_id, tracked_branches=raw_branches)
1298
+ return await self.list_repository_branches(repo_id=repo_id)
1299
+
1300
+ async def list_repository_branch_links(
1301
+ self,
1302
+ *,
1303
+ repo_id: uuid.UUID,
1304
+ branch: str | None = None,
1305
+ ) -> RepositoryBranchLinkListPayload:
1306
+ repository = await self._store.get_repository_by_id(repo_id)
1307
+ if repository is None:
1308
+ raise LookupError("Repository not found")
1309
+
1310
+ repo_id_str = str(repo_id)
1311
+ repositories = await self._store.list_repositories()
1312
+ links: list[RepositoryBranchLinkPayload] = []
1313
+ for candidate in repositories:
1314
+ for link in self._repository_branch_links(candidate):
1315
+ source_repo_id = str(link.get("source_repo_id", "") or "")
1316
+ target_repo_id = str(link.get("target_repo_id", "") or "")
1317
+ if repo_id_str not in {source_repo_id, target_repo_id}:
1318
+ continue
1319
+ if branch:
1320
+ if (
1321
+ source_repo_id == repo_id_str
1322
+ and str(link.get("source_branch", "") or "") != branch
1323
+ ):
1324
+ continue
1325
+ if (
1326
+ target_repo_id == repo_id_str
1327
+ and str(link.get("target_branch", "") or "") != branch
1328
+ ):
1329
+ continue
1330
+ links.append(self._serialize_branch_link(link))
1331
+
1332
+ links.sort(
1333
+ key=lambda item: (
1334
+ 0 if item["source_repo_id"] == repo_id_str else 1,
1335
+ item["source_branch"],
1336
+ item["target_repo_name"],
1337
+ item["target_branch"],
1338
+ item["relation"],
1339
+ )
1340
+ )
1341
+ return {
1342
+ "repo_id": repo_id_str,
1343
+ "branch": branch,
1344
+ "links": links,
1345
+ }
1346
+
1347
+ async def upsert_repository_branch_link(
1348
+ self,
1349
+ *,
1350
+ repo_id: uuid.UUID,
1351
+ source_branch: str,
1352
+ target_repo_id: str | None = None,
1353
+ target_repo_name: str | None = None,
1354
+ target_repo_url: str | None = None,
1355
+ target_branch: str,
1356
+ relation: str = "depends_on",
1357
+ direction: str = "outbound",
1358
+ confidence: float = 1.0,
1359
+ metadata: dict[str, Any] | None = None,
1360
+ ) -> RepositoryBranchLinkListPayload:
1361
+ repository = await self._store.get_repository_by_id(repo_id)
1362
+ if repository is None:
1363
+ raise LookupError("Repository not found")
1364
+
1365
+ normalized_source_branch = source_branch.strip()
1366
+ normalized_target_branch = target_branch.strip()
1367
+ if not normalized_source_branch:
1368
+ raise ValueError("Source branch is required")
1369
+ if not normalized_target_branch:
1370
+ raise ValueError("Target branch is required")
1371
+ if not (target_repo_id or target_repo_name or target_repo_url):
1372
+ raise ValueError("Target repository is required")
1373
+
1374
+ repositories = await self._store.list_repositories()
1375
+ new_links = self._build_branch_links(
1376
+ repository=repository,
1377
+ repositories=repositories,
1378
+ source_branch=normalized_source_branch,
1379
+ accepted_at=datetime.now(UTC).isoformat(),
1380
+ source="admin-console",
1381
+ specs=[
1382
+ {
1383
+ "source_branch": normalized_source_branch,
1384
+ "target_repo_id": target_repo_id,
1385
+ "target_repo_name": target_repo_name,
1386
+ "target_repo_url": target_repo_url,
1387
+ "target_branch": normalized_target_branch,
1388
+ "relation": relation,
1389
+ "direction": direction,
1390
+ "confidence": confidence,
1391
+ "metadata": metadata or {},
1392
+ }
1393
+ ],
1394
+ )
1395
+ relationships = dict(getattr(repository, "relationships", {}) or {})
1396
+ relationships["cross_repo_branches"] = self._merge_branch_links(
1397
+ self._repository_branch_links(repository),
1398
+ new_links,
1399
+ )
1400
+ tracked_branches = list(getattr(repository, "tracked_branches", None) or [])
1401
+ if (
1402
+ normalized_source_branch != getattr(repository, "default_branch", None)
1403
+ and normalized_source_branch not in tracked_branches
1404
+ ):
1405
+ tracked_branches.append(normalized_source_branch)
1406
+ await self._store.update_repository(
1407
+ repo_id,
1408
+ relationships=relationships,
1409
+ tracked_branches=tracked_branches,
1410
+ )
1411
+ return await self.list_repository_branch_links(
1412
+ repo_id=repo_id, branch=normalized_source_branch
1413
+ )
1414
+
1415
+ async def delete_repository_branch_link(
1416
+ self,
1417
+ *,
1418
+ repo_id: uuid.UUID,
1419
+ link_id: str,
1420
+ branch: str | None = None,
1421
+ ) -> RepositoryBranchLinkListPayload:
1422
+ repository = await self._store.get_repository_by_id(repo_id)
1423
+ if repository is None:
1424
+ raise LookupError("Repository not found")
1425
+
1426
+ existing_links = self._repository_branch_links(repository)
1427
+ filtered_links = [
1428
+ link for link in existing_links if str(link.get("id", "") or "") != link_id
1429
+ ]
1430
+ if len(filtered_links) == len(existing_links):
1431
+ raise LookupError("Repository branch link not found")
1432
+
1433
+ relationships = dict(getattr(repository, "relationships", {}) or {})
1434
+ relationships["cross_repo_branches"] = filtered_links
1435
+ await self._store.update_repository(repo_id, relationships=relationships)
1436
+ return await self.list_repository_branch_links(repo_id=repo_id, branch=branch)
1437
+
1438
+ async def list_repository_landscape(self) -> RepositoryLandscapePayload:
1439
+ repositories = await self._store.list_repositories()
1440
+ repository_payloads = [
1441
+ self.serialize_repository(repository) for repository in repositories
1442
+ ]
1443
+ nodes_by_id: dict[str, Any] = {}
1444
+ edges: list[Any] = []
1445
+
1446
+ for repository in repositories:
1447
+ repo_id_str = str(getattr(repository, "id"))
1448
+ repo_name = str(getattr(repository, "repo_name", "") or "")
1449
+ remote_url = _normalize_repository_remote(
1450
+ getattr(repository, "repo_url", None)
1451
+ )
1452
+ default_branch = str(getattr(repository, "default_branch", "") or "")
1453
+ for branch_name in self._repository_branch_names(repository):
1454
+ branch_state = self._repository_branch_state_payload(
1455
+ repository, branch_name
1456
+ )
1457
+ node_id = self._landscape_node_id(repo_id_str, branch_name)
1458
+ nodes_by_id[node_id] = {
1459
+ "id": node_id,
1460
+ "repo_id": repo_id_str,
1461
+ "repo_name": repo_name,
1462
+ "branch": branch_name,
1463
+ "remote_url": remote_url,
1464
+ "is_default": branch_name == default_branch,
1465
+ "last_synced": (
1466
+ branch_state["last_synced"]
1467
+ if branch_state is not None
1468
+ else None
1469
+ ),
1470
+ }
1471
+
1472
+ for repository in repositories:
1473
+ for link in self._repository_branch_links(repository):
1474
+ source_repo_id = str(link.get("source_repo_id", "") or "")
1475
+ source_branch = str(link.get("source_branch", "") or "")
1476
+ target_repo_id = str(link.get("target_repo_id", "") or "")
1477
+ target_repo_name = str(link.get("target_repo_name", "") or "")
1478
+ target_repo_url = _normalize_repository_remote(
1479
+ link.get("target_repo_url")
1480
+ )
1481
+ target_branch = str(link.get("target_branch", "") or "")
1482
+ if not source_repo_id or not source_branch or not target_branch:
1483
+ continue
1484
+
1485
+ source_node_id = self._landscape_node_id(source_repo_id, source_branch)
1486
+ if source_node_id not in nodes_by_id:
1487
+ continue
1488
+
1489
+ target_node_repo_id = target_repo_id or self._external_repo_key(
1490
+ target_repo_name, target_repo_url
1491
+ )
1492
+ target_node_id = self._landscape_node_id(
1493
+ target_node_repo_id, target_branch
1494
+ )
1495
+ if target_node_id not in nodes_by_id:
1496
+ nodes_by_id[target_node_id] = {
1497
+ "id": target_node_id,
1498
+ "repo_id": target_node_repo_id,
1499
+ "repo_name": target_repo_name or target_node_repo_id,
1500
+ "branch": target_branch,
1501
+ "remote_url": target_repo_url,
1502
+ "is_default": False,
1503
+ "last_synced": None,
1504
+ }
1505
+
1506
+ edges.append(
1507
+ {
1508
+ "id": str(link.get("id", "") or ""),
1509
+ "source_id": source_node_id,
1510
+ "target_id": target_node_id,
1511
+ "relation": str(
1512
+ link.get("relation", "depends_on") or "depends_on"
1513
+ ),
1514
+ "direction": str(
1515
+ link.get("direction", "outbound") or "outbound"
1516
+ ),
1517
+ "confidence": float(link.get("confidence", 1.0) or 1.0),
1518
+ }
1519
+ )
1520
+
1521
+ return {
1522
+ "repositories": repository_payloads,
1523
+ "nodes": sorted(
1524
+ nodes_by_id.values(),
1525
+ key=lambda item: (item["repo_name"], item["branch"]),
1526
+ ),
1527
+ "edges": sorted(
1528
+ edges,
1529
+ key=lambda item: (
1530
+ item["relation"],
1531
+ item["source_id"],
1532
+ item["target_id"],
1533
+ ),
1534
+ ),
1535
+ "summary": {
1536
+ "repo_count": len(repository_payloads),
1537
+ "branch_count": len(nodes_by_id),
1538
+ "link_count": len(edges),
1539
+ },
1540
+ }
1541
+
1542
+ @staticmethod
1543
+ def _repository_last_sync(repository: Any) -> dict[str, Any] | None:
1544
+ relationships = dict(getattr(repository, "relationships", {}) or {})
1545
+ graph_sync = relationships.get("graph_sync")
1546
+ if not isinstance(graph_sync, dict):
1547
+ return None
1548
+ last_sync = graph_sync.get("last_sync")
1549
+ if isinstance(last_sync, dict):
1550
+ return last_sync
1551
+ return graph_sync
1552
+
1553
+ async def _find_repository_for_client_sync(
1554
+ self,
1555
+ *,
1556
+ repo_name: str,
1557
+ repo_path: str,
1558
+ repo_url: str | None,
1559
+ ) -> Any | None:
1560
+ normalized_name = repo_name.strip()
1561
+ normalized_url = _normalize_repository_remote(repo_url)
1562
+
1563
+ repositories = await self._store.list_repositories()
1564
+ for repository in repositories:
1565
+ repository_name = str(getattr(repository, "repo_name", "") or "").strip()
1566
+ repository_url = _normalize_repository_remote(
1567
+ getattr(repository, "repo_url", None)
1568
+ )
1569
+ if normalized_url and repository_url and repository_url == normalized_url:
1570
+ return repository
1571
+ if (
1572
+ repository_name
1573
+ and repository_name == normalized_name
1574
+ and not repository_url
1575
+ ):
1576
+ return repository
1577
+ return None
1578
+
1579
+ @staticmethod
1580
+ def _repository_root_path(repository: Any) -> str | None:
1581
+ state_path = str(getattr(repository, "state_path", "") or "")
1582
+ if not state_path:
1583
+ return None
1584
+ state_root = Path(state_path)
1585
+ if state_root.name == ".minder":
1586
+ return str(state_root.parent)
1587
+ return str(state_root)
1588
+
1589
+ async def _repository_graph_nodes(
1590
+ self, repository: Any, *, branch: str | None = None
1591
+ ) -> list[Any]:
1592
+ _, repo_nodes = await self._graph_tools.list_repo_nodes(
1593
+ repo_id=str(getattr(repository, "id")),
1594
+ repo_name=str(getattr(repository, "repo_name", "") or ""),
1595
+ repo_path=self._repository_root_path(repository),
1596
+ branch=branch,
1597
+ )
1598
+ return repo_nodes
1599
+
1600
+ @staticmethod
1601
+ def _serialize_graph_node(node: Any) -> RepositoryGraphNodePayload:
1602
+ metadata = dict(getattr(node, "node_metadata", {}) or {})
1603
+ return {
1604
+ "id": str(getattr(node, "id")),
1605
+ "node_type": str(getattr(node, "node_type", "")),
1606
+ "name": str(getattr(node, "name", "")),
1607
+ "metadata": metadata,
1608
+ }
1609
+
1610
+ @staticmethod
1611
+ def _serialize_graph_edge(edge: Any) -> RepositoryGraphEdgePayload:
1612
+ return {
1613
+ "id": str(getattr(edge, "id")),
1614
+ "source_id": str(getattr(edge, "source_id")),
1615
+ "target_id": str(getattr(edge, "target_id")),
1616
+ "relation": str(getattr(edge, "relation", "")),
1617
+ "weight": float(getattr(edge, "weight", 1.0) or 1.0),
1618
+ }
1619
+
1620
+ def _serialize_repo_graph_nodes(
1621
+ self,
1622
+ nodes: list[Any],
1623
+ *,
1624
+ allowed_types: set[str],
1625
+ limit: int,
1626
+ ) -> list[RepositoryGraphNodePayload]:
1627
+ filtered = [
1628
+ node
1629
+ for node in nodes
1630
+ if str(getattr(node, "node_type", "")) in allowed_types
1631
+ ]
1632
+ filtered.sort(
1633
+ key=lambda node: (
1634
+ str(getattr(node, "node_type", "")),
1635
+ str(dict(getattr(node, "node_metadata", {}) or {}).get("path", "")),
1636
+ str(getattr(node, "name", "")),
1637
+ )
1638
+ )
1639
+ return [self._serialize_graph_node(node) for node in filtered[:limit]]
1640
+
1641
+ @staticmethod
1642
+ def _repository_branch_names(repository: Any) -> list[str]:
1643
+ ordered: list[str] = []
1644
+ default_branch = str(getattr(repository, "default_branch", "") or "").strip()
1645
+ if default_branch:
1646
+ ordered.append(default_branch)
1647
+ for branch_name in list(getattr(repository, "tracked_branches", None) or []):
1648
+ normalized = str(branch_name).strip()
1649
+ if normalized and normalized not in ordered:
1650
+ ordered.append(normalized)
1651
+ return ordered
1652
+
1653
+ @staticmethod
1654
+ def _repository_branch_state_payload(
1655
+ repository: Any,
1656
+ branch: str | None,
1657
+ ) -> RepositoryBranchPayload | None:
1658
+ normalized_branch = str(branch or "").strip()
1659
+ if not normalized_branch:
1660
+ return None
1661
+ relationships = dict(getattr(repository, "relationships", {}) or {})
1662
+ graph_sync = dict(relationships.get("graph_sync", {}) or {})
1663
+ branch_registry = dict(graph_sync.get("branches", {}) or {})
1664
+ branch_state = dict(branch_registry.get(normalized_branch, {}) or {})
1665
+ return {
1666
+ "branch": normalized_branch,
1667
+ "is_default": normalized_branch
1668
+ == getattr(repository, "default_branch", None),
1669
+ "last_synced": str(branch_state.get("accepted_at", "") or "") or None,
1670
+ "payload_version": str(branch_state.get("payload_version", "") or "")
1671
+ or None,
1672
+ "source": str(branch_state.get("source", "") or "") or None,
1673
+ "node_count": int(branch_state.get("nodes_upserted", 0) or 0),
1674
+ "edge_count": int(branch_state.get("edges_upserted", 0) or 0),
1675
+ "deleted_nodes": int(branch_state.get("deleted_nodes", 0) or 0),
1676
+ "repo_path": str(branch_state.get("repo_path", "") or "") or None,
1677
+ "diff_base": str(branch_state.get("diff_base", "") or "") or None,
1678
+ }
1679
+
1680
+ @staticmethod
1681
+ def _repository_branch_links(repository: Any) -> list[dict[str, Any]]:
1682
+ relationships = dict(getattr(repository, "relationships", {}) or {})
1683
+ raw_links = relationships.get("cross_repo_branches", [])
1684
+ if not isinstance(raw_links, list):
1685
+ return []
1686
+ return [dict(link) for link in raw_links if isinstance(link, dict)]
1687
+
1688
+ def _build_branch_links(
1689
+ self,
1690
+ *,
1691
+ repository: Any,
1692
+ repositories: list[Any],
1693
+ source_branch: str | None,
1694
+ accepted_at: str,
1695
+ source: str,
1696
+ specs: list[dict[str, Any]],
1697
+ ) -> list[dict[str, Any]]:
1698
+ source_repo_id = str(getattr(repository, "id"))
1699
+ source_repo_name = str(getattr(repository, "repo_name", "") or "")
1700
+ source_repo_url = _normalize_repository_remote(
1701
+ getattr(repository, "repo_url", None)
1702
+ )
1703
+ fallback_source_branch = str(
1704
+ source_branch or getattr(repository, "default_branch", None) or ""
1705
+ ).strip()
1706
+ built_links: list[dict[str, Any]] = []
1707
+
1708
+ for spec in specs:
1709
+ normalized_source_branch = str(
1710
+ spec.get("source_branch") or fallback_source_branch
1711
+ ).strip()
1712
+ target_branch = str(spec.get("target_branch", "") or "").strip()
1713
+ if not normalized_source_branch or not target_branch:
1714
+ continue
1715
+
1716
+ target_repository = self._resolve_repository_reference(
1717
+ repositories=repositories,
1718
+ target_repo_id=spec.get("target_repo_id"),
1719
+ target_repo_name=spec.get("target_repo_name"),
1720
+ target_repo_url=spec.get("target_repo_url"),
1721
+ )
1722
+ target_repo_id = (
1723
+ str(getattr(target_repository, "id"))
1724
+ if target_repository is not None
1725
+ else None
1726
+ )
1727
+ target_repo_name = (
1728
+ str(getattr(target_repository, "repo_name", "") or "")
1729
+ if target_repository is not None
1730
+ else str(spec.get("target_repo_name", "") or "").strip()
1731
+ )
1732
+ target_repo_url = (
1733
+ _normalize_repository_remote(
1734
+ getattr(target_repository, "repo_url", None)
1735
+ )
1736
+ if target_repository is not None
1737
+ else _normalize_repository_remote(spec.get("target_repo_url"))
1738
+ )
1739
+ target_key = target_repo_id or target_repo_url or target_repo_name
1740
+ if not target_key:
1741
+ continue
1742
+ relation = (
1743
+ str(spec.get("relation", "depends_on") or "depends_on").strip()
1744
+ or "depends_on"
1745
+ )
1746
+ direction = (
1747
+ str(spec.get("direction", "outbound") or "outbound").strip()
1748
+ or "outbound"
1749
+ )
1750
+ link_id = str(
1751
+ uuid.uuid5(
1752
+ uuid.NAMESPACE_URL,
1753
+ f"{source_repo_id}:{normalized_source_branch}:{relation}:{target_key}:{target_branch}",
1754
+ )
1755
+ )
1756
+ built_links.append(
1757
+ {
1758
+ "id": link_id,
1759
+ "source_repo_id": source_repo_id,
1760
+ "source_repo_name": source_repo_name,
1761
+ "source_repo_url": source_repo_url,
1762
+ "source_branch": normalized_source_branch,
1763
+ "target_repo_id": target_repo_id,
1764
+ "target_repo_name": target_repo_name,
1765
+ "target_repo_url": target_repo_url,
1766
+ "target_branch": target_branch,
1767
+ "relation": relation,
1768
+ "direction": direction,
1769
+ "confidence": float(spec.get("confidence", 1.0) or 1.0),
1770
+ "last_seen_at": accepted_at,
1771
+ "source": source,
1772
+ "metadata": dict(spec.get("metadata", {}) or {}),
1773
+ }
1774
+ )
1775
+
1776
+ return built_links
1777
+
1778
+ @staticmethod
1779
+ def _merge_branch_links(
1780
+ existing_links: list[dict[str, Any]],
1781
+ new_links: list[dict[str, Any]],
1782
+ ) -> list[dict[str, Any]]:
1783
+ merged = {
1784
+ str(link.get("id", "") or ""): dict(link)
1785
+ for link in existing_links
1786
+ if link.get("id")
1787
+ }
1788
+ for link in new_links:
1789
+ link_id = str(link.get("id", "") or "")
1790
+ if not link_id:
1791
+ continue
1792
+ merged[link_id] = {**dict(merged.get(link_id, {})), **dict(link)}
1793
+ return list(merged.values())
1794
+
1795
+ @staticmethod
1796
+ def _serialize_branch_link(link: dict[str, Any]) -> RepositoryBranchLinkPayload:
1797
+ return {
1798
+ "id": str(link.get("id", "") or ""),
1799
+ "source_repo_id": str(link.get("source_repo_id", "") or ""),
1800
+ "source_repo_name": str(link.get("source_repo_name", "") or ""),
1801
+ "source_repo_url": _normalize_repository_remote(
1802
+ link.get("source_repo_url")
1803
+ ),
1804
+ "source_branch": str(link.get("source_branch", "") or ""),
1805
+ "target_repo_id": str(link.get("target_repo_id", "") or "") or None,
1806
+ "target_repo_name": str(link.get("target_repo_name", "") or ""),
1807
+ "target_repo_url": _normalize_repository_remote(
1808
+ link.get("target_repo_url")
1809
+ ),
1810
+ "target_branch": str(link.get("target_branch", "") or ""),
1811
+ "relation": str(link.get("relation", "depends_on") or "depends_on"),
1812
+ "direction": str(link.get("direction", "outbound") or "outbound"),
1813
+ "confidence": float(link.get("confidence", 1.0) or 1.0),
1814
+ "last_seen_at": str(link.get("last_seen_at", "") or "") or None,
1815
+ "source": str(link.get("source", "") or "") or None,
1816
+ "metadata": dict(link.get("metadata", {}) or {}),
1817
+ }
1818
+
1819
+ @staticmethod
1820
+ def _resolve_repository_reference(
1821
+ *,
1822
+ repositories: list[Any],
1823
+ target_repo_id: Any,
1824
+ target_repo_name: Any,
1825
+ target_repo_url: Any,
1826
+ ) -> Any | None:
1827
+ normalized_target_id = str(target_repo_id or "").strip()
1828
+ normalized_target_name = str(target_repo_name or "").strip()
1829
+ normalized_target_url = _normalize_repository_remote(target_repo_url)
1830
+ for repository in repositories:
1831
+ if (
1832
+ normalized_target_id
1833
+ and str(getattr(repository, "id")) == normalized_target_id
1834
+ ):
1835
+ return repository
1836
+ if (
1837
+ normalized_target_url
1838
+ and _normalize_repository_remote(getattr(repository, "repo_url", None))
1839
+ == normalized_target_url
1840
+ ):
1841
+ return repository
1842
+ if (
1843
+ normalized_target_name
1844
+ and str(getattr(repository, "repo_name", "") or "")
1845
+ == normalized_target_name
1846
+ ):
1847
+ return repository
1848
+ return None
1849
+
1850
+ @staticmethod
1851
+ def _landscape_node_id(repo_id: str, branch: str) -> str:
1852
+ return f"{repo_id}:{branch}"
1853
+
1854
+ @staticmethod
1855
+ def _external_repo_key(repo_name: str, repo_url: str | None) -> str:
1856
+ normalized_name = repo_name.strip() or "external-repo"
1857
+ normalized_url = _normalize_repository_remote(repo_url)
1858
+ return f"external:{normalized_url or normalized_name}"
1859
+
1860
+
1861
+ def _normalize_repository_remote(repo_url: str | None) -> str | None:
1862
+ if repo_url is None:
1863
+ return None
1864
+ raw_url = str(repo_url).strip()
1865
+ if not raw_url:
1866
+ return None
1867
+ if raw_url.startswith("git@"):
1868
+ host_and_path = raw_url[4:]
1869
+ host, separator, path = host_and_path.partition(":")
1870
+ if separator and host and path:
1871
+ normalized_path = path.strip().lstrip("/").removesuffix(".git")
1872
+ if normalized_path:
1873
+ return f"git@{host}:{normalized_path}.git"
1874
+ return raw_url
1875
+ if (
1876
+ raw_url.startswith("ssh://")
1877
+ or raw_url.startswith("http://")
1878
+ or raw_url.startswith("https://")
1879
+ ):
1880
+ parts = urlsplit(raw_url)
1881
+ host = parts.hostname or ""
1882
+ path = parts.path.strip().lstrip("/").removesuffix(".git")
1883
+ user = parts.username or "git"
1884
+ if host and path:
1885
+ return f"{user}@{host}:{path}.git"
1886
+ return raw_url.rstrip("/")
1887
+
1888
+
1889
+ def _repo_name_from_remote(repo_url: str | None) -> str | None:
1890
+ normalized_url = _normalize_repository_remote(repo_url)
1891
+ if not normalized_url:
1892
+ return None
1893
+ _, _, path = normalized_url.partition(":")
1894
+ repo_name = path.rsplit("/", 1)[-1].removesuffix(".git").strip()
1895
+ return repo_name or None