minder-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|