gabriel / musehub public
repos.py python
456 lines 17.4 KB
Raw
sha256:c34df700fb7fbd0dce834abc439a2ac0860c6a59e036ec1cecc8f5ca3899ced7 Merge 'security/131-phase3-sweep-fixes' into 'dev' — propos… Human 5 hours ago
1 """Write executors for repository operations: create_repo, fork_repo, delete_repo, patch_repo_settings, transfer_repo_ownership."""
2
3 import logging
4 from typing import TYPE_CHECKING
5
6 from musehub.types.json_types import JSONObject, JSONValue
7 from musehub.db.database import AsyncSessionLocal
8
9 if TYPE_CHECKING:
10 from sqlalchemy.ext.asyncio import AsyncSession
11
12 type _PermRank = dict[str, int]
13 from musehub.db import musehub_collaborator_models as collab_db
14 from musehub.services import musehub_repository
15 from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available
16 from sqlalchemy import select
17
18 logger = logging.getLogger(__name__)
19
20 _PERMISSION_RANK: _PermRank = {
21 "read": 1,
22 "write": 2,
23 "admin": 3,
24 "owner": 4,
25 }
26
27
28 async def _require_owner(repo_owner: str, actor: str) -> MusehubToolResult | None:
29 """Return a 403 MusehubToolResult if *actor* is not the repo owner, else None."""
30 if actor != repo_owner:
31 return MusehubToolResult(
32 ok=False,
33 error_code="forbidden",
34 error_message="Only the repository owner may perform this action.",
35 )
36 return None
37
38
39 async def _require_owner_or_admin(
40 session: "AsyncSession",
41 repo_id: str,
42 repo_owner: str,
43 actor: str,
44 ) -> MusehubToolResult | None:
45 """Return a 403 MusehubToolResult if *actor* is not owner or admin collaborator, else None."""
46 if not actor:
47 return MusehubToolResult(
48 ok=False,
49 error_code="forbidden",
50 error_message="Authentication required.",
51 hint="Provide a valid MSign Authorization header.",
52 )
53 if actor == str(repo_owner):
54 return None
55 result = await session.execute(
56 select(collab_db.MusehubCollaborator).where(
57 collab_db.MusehubCollaborator.repo_id == repo_id,
58 collab_db.MusehubCollaborator.identity_handle == actor,
59 collab_db.MusehubCollaborator.accepted_at.isnot(None),
60 )
61 )
62 collab = result.scalar_one_or_none()
63 actor_perm = str(collab.permission) if collab is not None else ""
64 if _PERMISSION_RANK.get(actor_perm, 0) >= _PERMISSION_RANK["admin"]:
65 return None
66 return MusehubToolResult(
67 ok=False,
68 error_code="forbidden",
69 error_message="Owner or admin permission required.",
70 )
71
72
73 async def execute_create_repo(
74 *,
75 name: str,
76 owner: str,
77 owner_user_id: str,
78 description: str = "",
79 visibility: str = "public",
80 tags: list[str] | None = None,
81 initialize: bool = True,
82 ) -> MusehubToolResult:
83 """Create a new MuseHub repository owned by ``owner``.
84
85 ``owner_user_id`` must be a non-empty authenticated MSign handle; the
86 dispatcher always sets it from the verified token so an empty value
87 indicates an unauthenticated or misconfigured call.
88
89 Args:
90 name: Human-readable repo name (slug auto-generated).
91 owner: Username of the repo owner.
92 owner_user_id: Authenticated user ID (MSign handle). Must be non-empty.
93 description: Optional markdown description.
94 visibility: ``"public"`` (default) or ``"private"``.
95 tags: Optional list of tag strings.
96 initialize: When True (default) an empty initial commit + default branch are created.
97
98 Returns:
99 ``MusehubToolResult`` with ``data.repo_id`` on success.
100 """
101 if not owner_user_id:
102 return MusehubToolResult(
103 ok=False,
104 error_code="forbidden",
105 error_message="Authentication required to create a repository.",
106 hint="Provide a valid MSign Authorization header.",
107 )
108 if (err := _check_db_available()) is not None:
109 return err
110
111 try:
112 async with AsyncSessionLocal() as session:
113 owner_identity_id = await musehub_repository.get_identity_id_for_handle(session, owner_user_id)
114 repo = await musehub_repository.create_repo(
115 session,
116 name=name,
117 owner=owner,
118 visibility=visibility,
119 owner_user_id=owner_user_id,
120 owner_identity_id=owner_identity_id,
121 description=description,
122 tags=tags,
123 initialize=initialize,
124 )
125 await session.commit()
126 data: JSONObject = {
127 "repo_id": repo.repo_id,
128 "name": repo.name,
129 "slug": repo.slug,
130 "owner": repo.owner,
131 "visibility": repo.visibility,
132 "clone_url": repo.clone_url,
133 "created_at": repo.created_at.isoformat() if repo.created_at else None,
134 }
135 logger.info("MCP create_repo: %s/%s (%s)", owner, repo.slug, repo.repo_id)
136 return MusehubToolResult(ok=True, data=data)
137 except Exception as exc:
138 logger.exception("MCP create_repo failed: %s", exc)
139 return MusehubToolResult(
140 ok=False,
141 error_code="invalid_args",
142 error_message=str(exc),
143 )
144
145
146 async def execute_delete_repo(
147 *,
148 repo_id: str,
149 actor: str = "",
150 ) -> MusehubToolResult:
151 """Soft-delete a MuseHub repository.
152
153 Only the repository owner may delete a repo — admin collaborators are not
154 permitted. The deletion is soft: all data is retained in the database for
155 audit purposes; subsequent reads return 404.
156
157 Args:
158 repo_id: sha256 genesis ID of the repository to delete.
159 actor: Authenticated user ID (MSign handle). Must be the repo owner.
160
161 Returns:
162 ``MusehubToolResult`` with ``data.deleted=true`` on success.
163 """
164 if (err := _check_db_available()) is not None:
165 return err
166
167 if not actor:
168 return MusehubToolResult(
169 ok=False,
170 error_code="forbidden",
171 error_message="Authentication required to delete a repository.",
172 hint="Provide a valid MSign Authorization header.",
173 )
174
175 try:
176 async with AsyncSessionLocal() as session:
177 repo = await musehub_repository.get_repo(session, repo_id)
178 if repo is None:
179 return MusehubToolResult(
180 ok=False,
181 error_code="repo_not_found",
182 error_message=f"Repository '{repo_id}' not found.",
183 )
184 if (err := await _require_owner(repo.owner, actor)) is not None:
185 return err
186 deleted = await musehub_repository.delete_repo(session, repo_id)
187 if not deleted:
188 return MusehubToolResult(
189 ok=False,
190 error_code="repo_not_found",
191 error_message=f"Repository '{repo_id}' not found or already deleted.",
192 )
193 await session.commit()
194 logger.info("MCP delete_repo %s by %s", repo_id, actor)
195 return MusehubToolResult(ok=True, data={"deleted": True, "repo_id": repo_id})
196 except Exception as exc:
197 logger.exception("MCP delete_repo failed: %s", exc)
198 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
199
200
201 async def execute_update_repo(
202 *,
203 repo_id: str,
204 actor: str = "",
205 name: str | None = None,
206 description: str | None = None,
207 visibility: str | None = None,
208 default_branch: str | None = None,
209 has_issues: bool | None = None,
210 has_wiki: bool | None = None,
211 topics: list[str] | None = None,
212 homepage_url: str | None = None,
213 allow_merge_commit: bool | None = None,
214 allow_squash_merge: bool | None = None,
215 allow_rebase_merge: bool | None = None,
216 delete_branch_on_merge: bool | None = None,
217 ) -> MusehubToolResult:
218 """Partially update mutable settings for a repository.
219
220 Only non-None fields are written. The caller must be the repo owner or an
221 admin collaborator. ``visibility`` must be ``'public'`` or ``'private'``
222 when supplied. ``topics`` replaces the full tag list.
223
224 Args:
225 repo_id: sha256 genesis ID of the repository.
226 actor: Authenticated user ID (MSign handle).
227 name: New repo name.
228 description: New markdown description.
229 visibility: ``"public"`` or ``"private"``.
230 default_branch: New default branch name.
231 has_issues: Enable or disable the issues tracker.
232 has_wiki: Enable or disable the wiki.
233 topics: Full replacement topic tag list.
234 homepage_url: Project homepage URL.
235 allow_merge_commit: Allow merge commits on proposals.
236 allow_squash_merge: Allow squash merges on proposals.
237 allow_rebase_merge: Allow rebase merges on proposals.
238 delete_branch_on_merge: Auto-delete head branch after proposal merge.
239
240 Returns:
241 ``MusehubToolResult`` with the updated settings object on success.
242 """
243 if (err := _check_db_available()) is not None:
244 return err
245
246 if visibility is not None and visibility not in {"public", "private"}:
247 return MusehubToolResult(
248 ok=False,
249 error_code="invalid_args",
250 error_message="visibility must be 'public' or 'private'.",
251 )
252
253 try:
254 from musehub.models.musehub import RepoSettingsPatch
255 async with AsyncSessionLocal() as session:
256 repo = await musehub_repository.get_repo(session, repo_id)
257 if repo is None:
258 return MusehubToolResult(
259 ok=False,
260 error_code="repo_not_found",
261 error_message=f"Repository '{repo_id}' not found.",
262 )
263 if (err := await _require_owner_or_admin(session, repo_id, repo.owner, actor)) is not None:
264 return err
265 patch = RepoSettingsPatch(
266 name=name,
267 description=description,
268 visibility=visibility,
269 default_branch=default_branch,
270 has_issues=has_issues,
271 has_projects=None,
272 has_wiki=has_wiki,
273 topics=topics,
274 license=None,
275 homepage_url=homepage_url,
276 allow_merge_commit=allow_merge_commit,
277 allow_squash_merge=allow_squash_merge,
278 allow_rebase_merge=allow_rebase_merge,
279 delete_branch_on_merge=delete_branch_on_merge,
280 domain_id=None,
281 )
282 settings = await musehub_repository.update_repo_settings(session, repo_id, patch)
283 await session.commit()
284 if settings is None:
285 return MusehubToolResult(
286 ok=False,
287 error_code="repo_not_found",
288 error_message=f"Repository '{repo_id}' not found.",
289 )
290 logger.info("MCP patch_repo_settings %s by %s", repo_id, actor)
291 topics_val: list[JSONValue] = list(settings.topics)
292 data: JSONObject = {
293 "repo_id": repo_id,
294 "name": settings.name,
295 "description": settings.description,
296 "visibility": settings.visibility,
297 "default_branch": settings.default_branch,
298 "has_issues": settings.has_issues,
299 "has_wiki": settings.has_wiki,
300 "topics": topics_val,
301 "homepage_url": settings.homepage_url,
302 }
303 return MusehubToolResult(ok=True, data=data)
304 except Exception as exc:
305 logger.exception("MCP patch_repo_settings failed: %s", exc)
306 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
307
308
309 async def execute_transfer_repo_ownership(
310 *,
311 repo_id: str,
312 new_owner: str,
313 actor: str = "",
314 ) -> MusehubToolResult:
315 """Transfer ownership of a repository to another user.
316
317 Only the current owner may initiate a transfer — admin collaborators are not
318 permitted. After transfer the calling user loses owner privileges immediately.
319 The public ``owner`` username slug is NOT automatically changed; the new
320 owner may update it separately via settings.
321
322 Args:
323 repo_id: sha256 genesis ID of the repository.
324 new_owner: MSign handle of the new owner.
325 actor: Authenticated user ID (MSign handle). Must be current owner.
326
327 Returns:
328 ``MusehubToolResult`` with the updated repo record on success.
329 """
330 if (err := _check_db_available()) is not None:
331 return err
332
333 if not actor:
334 return MusehubToolResult(
335 ok=False,
336 error_code="forbidden",
337 error_message="Authentication required to transfer repository ownership.",
338 hint="Provide a valid MSign Authorization header.",
339 )
340
341 if not new_owner:
342 return MusehubToolResult(
343 ok=False,
344 error_code="invalid_args",
345 error_message="new_owner must be provided.",
346 )
347
348 try:
349 async with AsyncSessionLocal() as session:
350 repo = await musehub_repository.get_repo(session, repo_id)
351 if repo is None:
352 return MusehubToolResult(
353 ok=False,
354 error_code="repo_not_found",
355 error_message=f"Repository '{repo_id}' not found.",
356 )
357 if (err := await _require_owner(repo.owner, actor)) is not None:
358 return err
359 updated = await musehub_repository.transfer_repo_ownership(session, repo_id, new_owner)
360 await session.commit()
361 if updated is None:
362 return MusehubToolResult(
363 ok=False,
364 error_code="repo_not_found",
365 error_message=f"Repository '{repo_id}' not found.",
366 )
367 logger.info("MCP transfer_repo_ownership %s → %s by %s", repo_id, new_owner, actor)
368 data: JSONObject = {
369 "repo_id": updated.repo_id,
370 "name": updated.name,
371 "owner": updated.owner,
372 "owner_user_id": updated.owner_user_id,
373 "visibility": updated.visibility,
374 }
375 return MusehubToolResult(ok=True, data=data)
376 except Exception as exc:
377 logger.exception("MCP transfer_repo_ownership failed: %s", exc)
378 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
379
380
381 async def execute_fork_repo(
382 *,
383 source_repo_id: str,
384 actor: str = "",
385 name: str | None = None,
386 visibility: str = "public",
387 description: str | None = None,
388 ) -> MusehubToolResult:
389 """Fork a public repository into the authenticated caller's account.
390
391 Creates a new repository owned by ``actor`` and records the fork
392 relationship in ``musehub_forks``. The caller cannot fork their own
393 repo, and cannot fork the same source repo twice.
394
395 Args:
396 source_repo_id: sha256 genesis ID of the repo to fork.
397 actor: Authenticated MSign handle (owner of the new fork).
398 name: Optional name for the fork repo (defaults to source repo name).
399 visibility: ``"public"`` (default) or ``"private"``.
400 description: Optional description (defaults to "Fork of {owner}/{slug}: …").
401
402 Returns:
403 ``MusehubToolResult`` with the new fork entry on success.
404 """
405 if not actor:
406 return MusehubToolResult(
407 ok=False,
408 error_code="forbidden",
409 error_message="Authentication required to fork a repository.",
410 hint="Provide a valid MSign Authorization header.",
411 )
412 if (err := _check_db_available()) is not None:
413 return err
414
415 from musehub.models.musehub import ForkRepoRequest
416 from sqlalchemy.exc import IntegrityError
417
418 request = ForkRepoRequest(name=name, visibility=visibility, description=description)
419
420 try:
421 async with AsyncSessionLocal() as session:
422 try:
423 entry = await musehub_repository.fork_repo(
424 session,
425 source_repo_id=source_repo_id,
426 forked_by_handle=actor,
427 request=request,
428 )
429 await session.commit()
430 except ValueError as exc:
431 await session.rollback()
432 msg = str(exc)
433 code_map = {
434 "source_repo_not_found": ("repo_not_found", "Source repository not found."),
435 "source_repo_not_public": ("forbidden", "Only public repositories may be forked."),
436 "cannot_fork_own_repo": ("forbidden", "You cannot fork a repository you already own."),
437 "duplicate_fork": ("conflict", "You have already forked this repository."),
438 }
439 error_code, error_message = code_map.get(msg, ("invalid_args", msg))
440 return MusehubToolResult(ok=False, error_code=error_code, error_message=error_message)
441
442 logger.info("MCP fork_repo %s → new fork by %s", source_repo_id, actor)
443 data: JSONObject = {
444 "fork_id": entry.fork_id,
445 "fork_repo_id": entry.fork_repo.repo_id,
446 "fork_repo_name": entry.fork_repo.name,
447 "fork_repo_slug": entry.fork_repo.slug,
448 "fork_repo_owner": entry.fork_repo.owner,
449 "source_owner": entry.source_owner,
450 "source_slug": entry.source_slug,
451 "forked_at": entry.forked_at.isoformat(),
452 }
453 return MusehubToolResult(ok=True, data=data)
454 except Exception as exc:
455 logger.exception("MCP fork_repo failed: %s", exc)
456 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
File History 4 commits
sha256:c34df700fb7fbd0dce834abc439a2ac0860c6a59e036ec1cecc8f5ca3899ced7 Merge 'security/131-phase3-sweep-fixes' into 'dev' — propos… Human 5 hours ago
sha256:870c780aac4a349853d7d70e13028b278c34861dc60c8a1870419c4a4c56b819 Merge 'infra/ecr-logout-before-retry' into 'dev' — proposal… Human 5 hours ago
sha256:3fadb0439bba9451b89229676971c0d4a40900dec7810e9d5f8791b8d950d505 fix: install.sh version from latest published tarball, not … Sonnet 4.6 minor 103 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 124 days ago