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