repos.py
python
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