collaborators.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 collaborator operations: list, invite, update_permission, remove.""" |
| 2 | |
| 3 | import logging |
| 4 | from datetime import datetime, timezone |
| 5 | |
| 6 | from sqlalchemy import delete, select |
| 7 | |
| 8 | from musehub.core.genesis import compute_collaborator_id |
| 9 | from musehub.db.database import AsyncSessionLocal |
| 10 | from musehub.db.musehub_collaborator_models import MusehubCollaborator |
| 11 | from musehub.types.json_types import JSONObject |
| 12 | |
| 13 | type _PermRank = dict[str, int] |
| 14 | from musehub.services import musehub_repository |
| 15 | from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available |
| 16 | |
| 17 | logger = logging.getLogger(__name__) |
| 18 | |
| 19 | # Permission rank for comparison; mirrors the REST collaborators module. |
| 20 | _PERMISSION_RANK: _PermRank = { |
| 21 | "read": 1, |
| 22 | "write": 2, |
| 23 | "admin": 3, |
| 24 | "owner": 4, |
| 25 | } |
| 26 | |
| 27 | def _has_permission(actor_permission: str, required_rank: int) -> bool: |
| 28 | """Return True if *actor_permission* satisfies *required_rank*.""" |
| 29 | return _PERMISSION_RANK.get(actor_permission, 0) >= required_rank |
| 30 | |
| 31 | def _collab_data(row: MusehubCollaborator) -> JSONObject: |
| 32 | """Serialise a MusehubCollaborator ORM row to a ``JSONObject``.""" |
| 33 | return { |
| 34 | "collaborator_id": str(row.id), |
| 35 | "repo_id": str(row.repo_id), |
| 36 | "handle": str(row.identity_handle), |
| 37 | "permission": str(row.permission), |
| 38 | "invited_by": str(row.invited_by_handle) if row.invited_by_handle is not None else None, |
| 39 | } |
| 40 | |
| 41 | async def execute_list_collaborators( |
| 42 | *, |
| 43 | repo_id: str, |
| 44 | actor: str = "", |
| 45 | ) -> MusehubToolResult: |
| 46 | """Return all collaborators for a repository. |
| 47 | |
| 48 | Authentication is required (any valid MSign token). Being a collaborator is |
| 49 | not required to view the list — owners and admins commonly check collaborators |
| 50 | before inviting new ones. |
| 51 | |
| 52 | Args: |
| 53 | repo_id: sha256 genesis ID of the repository. |
| 54 | actor: Authenticated user ID (MSign handle). |
| 55 | |
| 56 | Returns: |
| 57 | ``MusehubToolResult`` with ``data.collaborators`` list on success. |
| 58 | """ |
| 59 | if (err := _check_db_available()) is not None: |
| 60 | return err |
| 61 | |
| 62 | if not actor: |
| 63 | return MusehubToolResult( |
| 64 | ok=False, |
| 65 | error_code="forbidden", |
| 66 | error_message="Authentication required to list collaborators.", |
| 67 | hint="Provide a valid MSign Authorization header.", |
| 68 | ) |
| 69 | |
| 70 | try: |
| 71 | async with AsyncSessionLocal() as session: |
| 72 | repo = await musehub_repository.get_repo(session, repo_id) |
| 73 | if repo is None: |
| 74 | return MusehubToolResult( |
| 75 | ok=False, |
| 76 | error_code="repo_not_found", |
| 77 | error_message=f"Repository '{repo_id}' not found.", |
| 78 | hint="Call musehub_search_repos() to find available repositories.", |
| 79 | ) |
| 80 | result = await session.execute( |
| 81 | select(MusehubCollaborator).where(MusehubCollaborator.repo_id == repo_id) |
| 82 | ) |
| 83 | rows = result.scalars().all() |
| 84 | collaborators: list[JSONObject] = [_collab_data(r) for r in rows] |
| 85 | return MusehubToolResult( |
| 86 | ok=True, |
| 87 | data={"collaborators": collaborators, "total": len(collaborators)}, |
| 88 | ) |
| 89 | except Exception as exc: |
| 90 | logger.exception("MCP list_collaborators failed: %s", exc) |
| 91 | return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc)) |
| 92 | |
| 93 | async def execute_invite_collaborator( |
| 94 | *, |
| 95 | repo_id: str, |
| 96 | handle: str, |
| 97 | permission: str = "write", |
| 98 | actor: str = "", |
| 99 | ) -> MusehubToolResult: |
| 100 | """Invite a user as a collaborator on a repository. |
| 101 | |
| 102 | The caller must be the repo owner or have admin permission. The invited user |
| 103 | receives the specified *permission* level (read | write | admin). Attempting to |
| 104 | invite an existing collaborator returns ``error_code="conflict"``. |
| 105 | |
| 106 | Args: |
| 107 | repo_id: sha256 genesis ID of the repository. |
| 108 | handle: MSign handle of the user to invite. |
| 109 | permission: Permission level: ``"read"``, ``"write"`` (default), or ``"admin"``. |
| 110 | actor: Authenticated user ID (MSign handle). |
| 111 | |
| 112 | Returns: |
| 113 | ``MusehubToolResult`` with the new collaborator record on success. |
| 114 | """ |
| 115 | if (err := _check_db_available()) is not None: |
| 116 | return err |
| 117 | |
| 118 | if not actor: |
| 119 | return MusehubToolResult( |
| 120 | ok=False, |
| 121 | error_code="forbidden", |
| 122 | error_message="Authentication required to invite collaborators.", |
| 123 | hint="Provide a valid MSign Authorization header.", |
| 124 | ) |
| 125 | |
| 126 | valid_permissions = {"read", "write", "admin"} |
| 127 | if permission not in valid_permissions: |
| 128 | return MusehubToolResult( |
| 129 | ok=False, |
| 130 | error_code="invalid_args", |
| 131 | error_message=f"permission must be one of {sorted(valid_permissions)}.", |
| 132 | ) |
| 133 | |
| 134 | try: |
| 135 | async with AsyncSessionLocal() as session: |
| 136 | repo = await musehub_repository.get_repo(session, repo_id) |
| 137 | if repo is None: |
| 138 | return MusehubToolResult( |
| 139 | ok=False, |
| 140 | error_code="repo_not_found", |
| 141 | error_message=f"Repository '{repo_id}' not found.", |
| 142 | hint="Call musehub_search_repos() to find available repositories.", |
| 143 | ) |
| 144 | |
| 145 | repo_owner = str(repo.owner) |
| 146 | |
| 147 | # Determine actor permission. |
| 148 | actor_result = await session.execute( |
| 149 | select(MusehubCollaborator).where( |
| 150 | MusehubCollaborator.repo_id == repo_id, |
| 151 | MusehubCollaborator.identity_handle == actor, |
| 152 | ) |
| 153 | ) |
| 154 | actor_collab = actor_result.scalar_one_or_none() |
| 155 | actor_perm = str(actor_collab.permission) if actor_collab is not None else "" |
| 156 | |
| 157 | if actor != repo_owner and not _has_permission(actor_perm, _PERMISSION_RANK["admin"]): |
| 158 | return MusehubToolResult( |
| 159 | ok=False, |
| 160 | error_code="forbidden", |
| 161 | error_message="Admin or owner permission required to invite collaborators.", |
| 162 | ) |
| 163 | |
| 164 | # Reject duplicate. |
| 165 | existing = await session.execute( |
| 166 | select(MusehubCollaborator).where( |
| 167 | MusehubCollaborator.repo_id == repo_id, |
| 168 | MusehubCollaborator.identity_handle == handle, |
| 169 | ) |
| 170 | ) |
| 171 | if existing.scalar_one_or_none() is not None: |
| 172 | return MusehubToolResult( |
| 173 | ok=False, |
| 174 | error_code="conflict", |
| 175 | error_message=f"'{handle}' is already a collaborator on this repository.", |
| 176 | ) |
| 177 | |
| 178 | invitee_identity_id = await musehub_repository.get_identity_id_for_handle(session, handle) |
| 179 | now = datetime.now(tz=timezone.utc) |
| 180 | new_collab = MusehubCollaborator( |
| 181 | id=compute_collaborator_id(repo_id, invitee_identity_id or handle, now.isoformat()), |
| 182 | repo_id=repo_id, |
| 183 | identity_handle=handle, |
| 184 | permission=permission, |
| 185 | invited_by_handle=actor, |
| 186 | invited_at=now, |
| 187 | ) |
| 188 | session.add(new_collab) |
| 189 | await session.commit() |
| 190 | await session.refresh(new_collab) |
| 191 | logger.info("MCP invite_collaborator %s → %s in %s (perm=%s) by %s", handle, repo_id, repo_id, permission, actor) |
| 192 | return MusehubToolResult(ok=True, data=_collab_data(new_collab)) |
| 193 | except Exception as exc: |
| 194 | logger.exception("MCP invite_collaborator failed: %s", exc) |
| 195 | return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc)) |
| 196 | |
| 197 | async def execute_update_collaborator_permission( |
| 198 | *, |
| 199 | repo_id: str, |
| 200 | handle: str, |
| 201 | permission: str, |
| 202 | actor: str = "", |
| 203 | ) -> MusehubToolResult: |
| 204 | """Update a collaborator's permission level. |
| 205 | |
| 206 | The caller must be the repo owner or have admin permission. The owner's own |
| 207 | permission cannot be changed through this tool. |
| 208 | |
| 209 | Args: |
| 210 | repo_id: sha256 genesis ID of the repository. |
| 211 | handle: MSign handle of the collaborator whose permission is being changed. |
| 212 | permission: New permission level: ``"read"``, ``"write"``, or ``"admin"``. |
| 213 | actor: Authenticated user ID (MSign handle). |
| 214 | |
| 215 | Returns: |
| 216 | ``MusehubToolResult`` with the updated collaborator record on success. |
| 217 | """ |
| 218 | if (err := _check_db_available()) is not None: |
| 219 | return err |
| 220 | |
| 221 | if not actor: |
| 222 | return MusehubToolResult( |
| 223 | ok=False, |
| 224 | error_code="forbidden", |
| 225 | error_message="Authentication required to update collaborator permissions.", |
| 226 | hint="Provide a valid MSign Authorization header.", |
| 227 | ) |
| 228 | |
| 229 | valid_permissions = {"read", "write", "admin"} |
| 230 | if permission not in valid_permissions: |
| 231 | return MusehubToolResult( |
| 232 | ok=False, |
| 233 | error_code="invalid_args", |
| 234 | error_message=f"permission must be one of {sorted(valid_permissions)}.", |
| 235 | ) |
| 236 | |
| 237 | try: |
| 238 | async with AsyncSessionLocal() as session: |
| 239 | repo = await musehub_repository.get_repo(session, repo_id) |
| 240 | if repo is None: |
| 241 | return MusehubToolResult( |
| 242 | ok=False, |
| 243 | error_code="repo_not_found", |
| 244 | error_message=f"Repository '{repo_id}' not found.", |
| 245 | ) |
| 246 | |
| 247 | repo_owner = str(repo.owner) |
| 248 | |
| 249 | actor_result = await session.execute( |
| 250 | select(MusehubCollaborator).where( |
| 251 | MusehubCollaborator.repo_id == repo_id, |
| 252 | MusehubCollaborator.identity_handle == actor, |
| 253 | ) |
| 254 | ) |
| 255 | actor_collab = actor_result.scalar_one_or_none() |
| 256 | actor_perm = str(actor_collab.permission) if actor_collab is not None else "" |
| 257 | |
| 258 | if actor != repo_owner and not _has_permission(actor_perm, _PERMISSION_RANK["admin"]): |
| 259 | return MusehubToolResult( |
| 260 | ok=False, |
| 261 | error_code="forbidden", |
| 262 | error_message="Admin or owner permission required to update collaborator permissions.", |
| 263 | ) |
| 264 | |
| 265 | target_result = await session.execute( |
| 266 | select(MusehubCollaborator).where( |
| 267 | MusehubCollaborator.repo_id == repo_id, |
| 268 | MusehubCollaborator.identity_handle == handle, |
| 269 | ) |
| 270 | ) |
| 271 | target = target_result.scalar_one_or_none() |
| 272 | if target is None: |
| 273 | return MusehubToolResult( |
| 274 | ok=False, |
| 275 | error_code="collaborator_not_found", |
| 276 | error_message=f"'{handle}' is not a collaborator on this repository.", |
| 277 | ) |
| 278 | |
| 279 | if str(target.permission) == "owner": |
| 280 | return MusehubToolResult( |
| 281 | ok=False, |
| 282 | error_code="forbidden", |
| 283 | error_message="The repository owner's permission cannot be changed.", |
| 284 | ) |
| 285 | |
| 286 | target.permission = permission |
| 287 | await session.commit() |
| 288 | await session.refresh(target) |
| 289 | logger.info("MCP update_collaborator_permission %s in %s → %s by %s", handle, repo_id, permission, actor) |
| 290 | return MusehubToolResult(ok=True, data=_collab_data(target)) |
| 291 | except Exception as exc: |
| 292 | logger.exception("MCP update_collaborator_permission failed: %s", exc) |
| 293 | return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc)) |
| 294 | |
| 295 | async def execute_remove_collaborator( |
| 296 | *, |
| 297 | repo_id: str, |
| 298 | handle: str, |
| 299 | actor: str = "", |
| 300 | ) -> MusehubToolResult: |
| 301 | """Remove a collaborator from a repository. |
| 302 | |
| 303 | The caller must be the repo owner or have admin permission. The repository |
| 304 | owner cannot be removed. |
| 305 | |
| 306 | Args: |
| 307 | repo_id: sha256 genesis ID of the repository. |
| 308 | handle: MSign handle of the collaborator to remove. |
| 309 | actor: Authenticated user ID (MSign handle). |
| 310 | |
| 311 | Returns: |
| 312 | ``MusehubToolResult`` with ``data.removed=true`` on success. |
| 313 | """ |
| 314 | if (err := _check_db_available()) is not None: |
| 315 | return err |
| 316 | |
| 317 | if not actor: |
| 318 | return MusehubToolResult( |
| 319 | ok=False, |
| 320 | error_code="forbidden", |
| 321 | error_message="Authentication required to remove collaborators.", |
| 322 | hint="Provide a valid MSign Authorization header.", |
| 323 | ) |
| 324 | |
| 325 | try: |
| 326 | async with AsyncSessionLocal() as session: |
| 327 | repo = await musehub_repository.get_repo(session, repo_id) |
| 328 | if repo is None: |
| 329 | return MusehubToolResult( |
| 330 | ok=False, |
| 331 | error_code="repo_not_found", |
| 332 | error_message=f"Repository '{repo_id}' not found.", |
| 333 | ) |
| 334 | |
| 335 | repo_owner = str(repo.owner) |
| 336 | |
| 337 | actor_result = await session.execute( |
| 338 | select(MusehubCollaborator).where( |
| 339 | MusehubCollaborator.repo_id == repo_id, |
| 340 | MusehubCollaborator.identity_handle == actor, |
| 341 | ) |
| 342 | ) |
| 343 | actor_collab = actor_result.scalar_one_or_none() |
| 344 | actor_perm = str(actor_collab.permission) if actor_collab is not None else "" |
| 345 | |
| 346 | if actor != repo_owner and not _has_permission(actor_perm, _PERMISSION_RANK["admin"]): |
| 347 | return MusehubToolResult( |
| 348 | ok=False, |
| 349 | error_code="forbidden", |
| 350 | error_message="Admin or owner permission required to remove collaborators.", |
| 351 | ) |
| 352 | |
| 353 | target_result = await session.execute( |
| 354 | select(MusehubCollaborator).where( |
| 355 | MusehubCollaborator.repo_id == repo_id, |
| 356 | MusehubCollaborator.identity_handle == handle, |
| 357 | ) |
| 358 | ) |
| 359 | target = target_result.scalar_one_or_none() |
| 360 | if target is None: |
| 361 | return MusehubToolResult( |
| 362 | ok=False, |
| 363 | error_code="collaborator_not_found", |
| 364 | error_message=f"'{handle}' is not a collaborator on this repository.", |
| 365 | ) |
| 366 | |
| 367 | if str(target.permission) == "owner": |
| 368 | return MusehubToolResult( |
| 369 | ok=False, |
| 370 | error_code="forbidden", |
| 371 | error_message="The repository owner cannot be removed as a collaborator.", |
| 372 | ) |
| 373 | |
| 374 | await session.execute( |
| 375 | delete(MusehubCollaborator).where( |
| 376 | MusehubCollaborator.repo_id == repo_id, |
| 377 | MusehubCollaborator.identity_handle == handle, |
| 378 | ) |
| 379 | ) |
| 380 | await session.commit() |
| 381 | logger.info("MCP remove_collaborator %s from %s by %s", handle, repo_id, actor) |
| 382 | return MusehubToolResult(ok=True, data={"removed": True, "handle": handle}) |
| 383 | except Exception as exc: |
| 384 | logger.exception("MCP remove_collaborator failed: %s", exc) |
| 385 | 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