gabriel / musehub public
collaborators.py python
385 lines 14.8 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 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